Repository: SignalK/signalk-server Branch: master Commit: d602bd648e17 Files: 687 Total size: 9.1 MB Directory structure: gitextract_s_vrwrw7/ ├── .coderabbit.yaml ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── build-base-image.yml │ ├── build-docker.yml │ ├── plugin-ci.yml │ ├── release.yml │ ├── require_pr_label.yml │ ├── security-scan.yml │ └── test.yml ├── .gitignore ├── .mocharc.js ├── .npmignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .python-version ├── AGENTS.md ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── docker/ │ ├── Dockerfile │ ├── Dockerfile_base_24.04 │ ├── Dockerfile_base_alpine │ ├── Dockerfile_rel │ ├── README.md │ ├── avahi/ │ │ └── avahi-dbus.conf │ ├── bluez/ │ │ └── bluezuser.conf │ ├── docker-compose.yml │ ├── startup.sh │ └── v2_demo/ │ ├── Dockerfile │ ├── course-data.json │ ├── resources/ │ │ ├── routes/ │ │ │ ├── ad825f6c-1ae9-4f76-abc4-df2866b14b78 │ │ │ └── da825f6c-1ae9-4f76-abc4-df2866b14b78 │ │ └── waypoints/ │ │ ├── ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a │ │ └── afe46290-aa98-4d2f-9c04-d199ca64942e │ ├── resources-provider.json │ ├── serverstate/ │ │ └── course/ │ │ └── settings.json │ └── startup_heroku_demo.sh ├── docs/ │ ├── README.md │ ├── breaking_changes.md │ ├── develop/ │ │ ├── README.md │ │ ├── plugins/ │ │ │ ├── README.md │ │ │ ├── autopilot_provider_plugins.md │ │ │ ├── backpressure.md │ │ │ ├── ci.md │ │ │ ├── configuration.md │ │ │ ├── course_calculations.md │ │ │ ├── custom_renderers.md │ │ │ ├── deltas.md │ │ │ ├── examples/ │ │ │ │ ├── plugin-caller-example.yml │ │ │ │ ├── plugin-dependabot-example.yml │ │ │ │ └── plugin-release-example.yml │ │ │ ├── publishing.md │ │ │ ├── release.md │ │ │ ├── resource_provider_plugins.md │ │ │ ├── wasm/ │ │ │ │ ├── README.md │ │ │ │ ├── assemblyscript.md │ │ │ │ ├── best_practices.md │ │ │ │ ├── capabilities.md │ │ │ │ ├── deltas.md │ │ │ │ ├── go.md │ │ │ │ ├── http_endpoints.md │ │ │ │ ├── integration_guide.md │ │ │ │ └── rust.md │ │ │ └── weather_provider_plugins.md │ │ ├── rest-api/ │ │ │ ├── README.md │ │ │ ├── autopilot_api.md │ │ │ ├── conventions.md │ │ │ ├── course_api.md │ │ │ ├── history_api.md │ │ │ ├── notifications_api.md │ │ │ ├── plugin_api.md │ │ │ ├── proposed/ │ │ │ │ ├── README.md │ │ │ │ └── anchor_api.md │ │ │ ├── radar_api.md │ │ │ ├── resources_api.md │ │ │ └── weather_api.md │ │ └── webapps.md │ ├── guides/ │ │ ├── README.md │ │ ├── anchoralarm/ │ │ │ └── anchoralarm.md │ │ ├── datalogging/ │ │ │ └── datalogging.md │ │ ├── navdataserver/ │ │ │ └── navdataserver.md │ │ ├── udev.md │ │ └── unitpreferences.md │ ├── img/ │ │ ├── autopilot_provider.dia │ │ ├── course_provider.dia │ │ ├── notification_manager.dia │ │ ├── resource_provider.dia │ │ └── server_only.dia │ ├── installation/ │ │ ├── README.md │ │ ├── command_line.md │ │ ├── docker.md │ │ ├── npm.md │ │ ├── raspberry_pi_installation.md │ │ ├── source.md │ │ └── updating.md │ ├── internal/ │ │ ├── README.md │ │ ├── wasm-architecture.md │ │ └── wasm-asyncify.md │ ├── oidc.md │ ├── security-architecture.md │ ├── security.md │ ├── setup/ │ │ ├── configuration.md │ │ ├── generating_tokens.md │ │ ├── nmea.md │ │ └── seatalk/ │ │ └── README.md │ ├── src/ │ │ └── features/ │ │ └── weather/ │ │ └── weather.md │ ├── support/ │ │ ├── help.md │ │ └── sponsor.md │ └── whats_new.md ├── empty_file ├── eslint.config.js ├── examples/ │ └── wasm-plugins/ │ ├── example-anchor-watch-rust/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── Cargo.toml │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── lib.rs │ │ └── wit/ │ │ └── signalk-plugin.wit │ ├── example-hello-assemblyscript/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── asconfig.json │ │ ├── assembly/ │ │ │ └── index.ts │ │ └── package.json │ ├── example-routes-waypoints/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── asconfig.json │ │ ├── assembly/ │ │ │ └── index.ts │ │ └── package.json │ ├── example-weather-plugin/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── asconfig.json │ │ ├── assembly/ │ │ │ └── index.ts │ │ └── package.json │ └── example-weather-provider/ │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── asconfig.json │ ├── assembly/ │ │ └── index.ts │ └── package.json ├── fly_io/ │ ├── cr_signalk_io/ │ │ ├── Dockerfile │ │ ├── fly.toml │ │ └── nginx.conf │ └── demo_signalk_org/ │ ├── Dockerfile │ ├── fly.toml │ └── security.json ├── index.js ├── kubernetes/ │ ├── README.md │ ├── signalk-deployment.yaml │ └── signalk-ingress.yaml ├── kubernetes.md ├── package.json ├── packages/ │ ├── assemblyscript-plugin-sdk/ │ │ ├── .npmignore │ │ ├── LICENSE │ │ ├── README.md │ │ ├── asconfig.json │ │ ├── assembly/ │ │ │ ├── api.ts │ │ │ ├── index.ts │ │ │ ├── network.ts │ │ │ ├── plugin.ts │ │ │ ├── resources.ts │ │ │ └── signalk.ts │ │ ├── build/ │ │ │ ├── plugin.d.ts │ │ │ └── plugin.js │ │ └── package.json │ ├── resources-provider-plugin/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── @types/ │ │ │ │ └── geojson-validation.d.ts │ │ │ ├── index.ts │ │ │ ├── openApi.json │ │ │ └── types/ │ │ │ ├── index.ts │ │ │ └── store.ts │ │ └── tsconfig.json │ ├── server-admin-ui/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── index.html │ │ ├── package.json │ │ ├── public_src/ │ │ │ └── index.html │ │ ├── scss/ │ │ │ ├── _bootstrap-variables.scss │ │ │ ├── _core-variables.scss │ │ │ ├── _custom.scss │ │ │ ├── core/ │ │ │ │ ├── _animate.scss │ │ │ │ ├── _aside.scss │ │ │ │ ├── _avatars.scss │ │ │ │ ├── _badge.scss │ │ │ │ ├── _breadcrumb-menu.scss │ │ │ │ ├── _breadcrumb.scss │ │ │ │ ├── _buttons.scss │ │ │ │ ├── _callout.scss │ │ │ │ ├── _card.scss │ │ │ │ ├── _charts.scss │ │ │ │ ├── _dropdown-menu-right.scss │ │ │ │ ├── _dropdown.scss │ │ │ │ ├── _footer.scss │ │ │ │ ├── _grid.scss │ │ │ │ ├── _input-group.scss │ │ │ │ ├── _layout.scss │ │ │ │ ├── _loading.scss │ │ │ │ ├── _mixins.scss │ │ │ │ ├── _mobile.scss │ │ │ │ ├── _modal.scss │ │ │ │ ├── _nav.scss │ │ │ │ ├── _navbar.scss │ │ │ │ ├── _others.scss │ │ │ │ ├── _progress.scss │ │ │ │ ├── _rtl.scss │ │ │ │ ├── _sidebar.scss │ │ │ │ ├── _switches.scss │ │ │ │ ├── _tables.scss │ │ │ │ ├── _temp.scss │ │ │ │ ├── _typography.scss │ │ │ │ ├── _utilities.scss │ │ │ │ ├── _variables.scss │ │ │ │ ├── _widgets.scss │ │ │ │ ├── core.scss │ │ │ │ └── utilities/ │ │ │ │ ├── _background.scss │ │ │ │ ├── _borders.scss │ │ │ │ └── _display.scss │ │ │ ├── style.scss │ │ │ └── vendors/ │ │ │ ├── _variables.scss │ │ │ └── chart.js/ │ │ │ └── chart.scss │ │ ├── src/ │ │ │ ├── actions.ts │ │ │ ├── blinking-circle.css │ │ │ ├── bootstrap.tsx │ │ │ ├── components/ │ │ │ │ ├── Aside/ │ │ │ │ │ └── Aside.tsx │ │ │ │ ├── Footer/ │ │ │ │ │ └── Footer.tsx │ │ │ │ ├── Header/ │ │ │ │ │ └── Header.tsx │ │ │ │ ├── Icons.tsx │ │ │ │ ├── Sidebar/ │ │ │ │ │ └── Sidebar.tsx │ │ │ │ ├── SidebarFooter/ │ │ │ │ │ └── SidebarFooter.tsx │ │ │ │ ├── SidebarForm/ │ │ │ │ │ └── SidebarForm.tsx │ │ │ │ ├── SidebarHeader/ │ │ │ │ │ └── SidebarHeader.tsx │ │ │ │ └── SidebarMinimizer/ │ │ │ │ └── SidebarMinimizer.tsx │ │ │ ├── containers/ │ │ │ │ └── Full/ │ │ │ │ └── Full.tsx │ │ │ ├── contexts/ │ │ │ │ └── WebSocketContext.tsx │ │ │ ├── dataFetching.ts │ │ │ ├── dependency-sync.test.ts │ │ │ ├── fa-pulse.css │ │ │ ├── hooks/ │ │ │ │ └── useWebSocket.ts │ │ │ ├── index.ts │ │ │ ├── routes.ts │ │ │ ├── services/ │ │ │ │ └── WebSocketService.ts │ │ │ ├── store/ │ │ │ │ ├── index.ts │ │ │ │ ├── slices/ │ │ │ │ │ ├── appSlice.test.ts │ │ │ │ │ ├── appSlice.ts │ │ │ │ │ ├── dataSlice.test.ts │ │ │ │ │ ├── dataSlice.ts │ │ │ │ │ ├── prioritiesSlice.test.ts │ │ │ │ │ ├── prioritiesSlice.ts │ │ │ │ │ ├── unitPreferencesSlice.ts │ │ │ │ │ ├── wsSlice.test.ts │ │ │ │ │ └── wsSlice.ts │ │ │ │ └── types.ts │ │ │ ├── test/ │ │ │ │ └── setup.ts │ │ │ ├── types/ │ │ │ │ └── jsonlint-mod.d.ts │ │ │ ├── utils/ │ │ │ │ └── unitConversion.ts │ │ │ └── views/ │ │ │ ├── Configuration/ │ │ │ │ ├── Configuration.tsx │ │ │ │ └── EmbeddedPluginConfigurationForm.tsx │ │ │ ├── Dashboard/ │ │ │ │ └── Dashboard.tsx │ │ │ ├── DataBrowser/ │ │ │ │ ├── CopyToClipboardWithFade.tsx │ │ │ │ ├── DataBrowser.tsx │ │ │ │ ├── DataRow.tsx │ │ │ │ ├── GranularSubscriptionManager.ts │ │ │ │ ├── Meta.tsx │ │ │ │ ├── TimestampCell.tsx │ │ │ │ ├── ValueRenderers.tsx │ │ │ │ ├── VirtualTable.css │ │ │ │ ├── VirtualizedDataTable.tsx │ │ │ │ ├── VirtualizedMetaTable.tsx │ │ │ │ ├── pathUtils.ts │ │ │ │ └── usePathData.ts │ │ │ ├── Playground.tsx │ │ │ ├── ServerConfig/ │ │ │ │ ├── BackupRestore.tsx │ │ │ │ ├── BasicProvider.tsx │ │ │ │ ├── Logging.tsx │ │ │ │ ├── N2KFilters.tsx │ │ │ │ ├── PluginConfigurationForm.tsx │ │ │ │ ├── ProvidersConfiguration.tsx │ │ │ │ ├── ServerLog.tsx │ │ │ │ ├── ServerUpdate.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── SourcePriorities.tsx │ │ │ │ ├── UnitPreferencesSettings.tsx │ │ │ │ └── VesselConfiguration.tsx │ │ │ ├── Webapps/ │ │ │ │ ├── Embedded.tsx │ │ │ │ ├── EmbeddedAsyncApi.tsx │ │ │ │ ├── EmbeddedDocs.tsx │ │ │ │ ├── Webapp.tsx │ │ │ │ ├── Webapps.tsx │ │ │ │ ├── dynamicutilities.ts │ │ │ │ └── loadingerror.tsx │ │ │ ├── appstore/ │ │ │ │ ├── Apps/ │ │ │ │ │ ├── Apps.tsx │ │ │ │ │ └── WarningBox.tsx │ │ │ │ ├── AppsList.tsx │ │ │ │ ├── Grid/ │ │ │ │ │ └── cell-renderers/ │ │ │ │ │ ├── ActionCellRenderer.test.tsx │ │ │ │ │ └── ActionCellRenderer.tsx │ │ │ │ └── appStore.scss │ │ │ └── security/ │ │ │ ├── AccessRequests.tsx │ │ │ ├── Devices.tsx │ │ │ ├── EnableSecurity.tsx │ │ │ ├── Login.tsx │ │ │ ├── OIDCSettings.tsx │ │ │ ├── Register.tsx │ │ │ ├── Settings.tsx │ │ │ └── Users.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── server-admin-ui-dependencies/ │ │ ├── index.js │ │ └── package.json │ ├── server-api/ │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .npmrc │ │ ├── package.json │ │ ├── src/ │ │ │ ├── autopilotapi.ts │ │ │ ├── brand.ts │ │ │ ├── course.ts │ │ │ ├── coursetypes.ts │ │ │ ├── deltas.test.ts │ │ │ ├── deltas.ts │ │ │ ├── features.ts │ │ │ ├── history.ts │ │ │ ├── index.ts │ │ │ ├── mmsi/ │ │ │ │ ├── mid.ts │ │ │ │ ├── mmsi.test.ts │ │ │ │ └── mmsi.ts │ │ │ ├── notificationsapi.ts │ │ │ ├── plugin.ts │ │ │ ├── propertyvalues.test.ts │ │ │ ├── propertyvalues.ts │ │ │ ├── radarapi.ts │ │ │ ├── resourcesapi.ts │ │ │ ├── resourcetypes.ts │ │ │ ├── serverapi.ts │ │ │ ├── streambundle.ts │ │ │ ├── subscriptionmanager.ts │ │ │ ├── typebox/ │ │ │ │ ├── autopilot-schemas.ts │ │ │ │ ├── course-schemas.ts │ │ │ │ ├── discovery-schemas.ts │ │ │ │ ├── history-schemas.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notifications-schemas.ts │ │ │ │ ├── protocol-schemas.ts │ │ │ │ ├── radar-schemas.ts │ │ │ │ ├── resources-schemas.ts │ │ │ │ ├── shared-schemas.ts │ │ │ │ └── weather-schemas.ts │ │ │ ├── weatherapi.guard.ts │ │ │ └── weatherapi.ts │ │ ├── tsconfig.json │ │ ├── typedoc.json │ │ └── wit/ │ │ └── signalk.wit │ ├── streams/ │ │ ├── .npmrc │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── actisense-serial.ts │ │ │ ├── autodetect.test.ts │ │ │ ├── autodetect.ts │ │ │ ├── canboatjs.test.ts │ │ │ ├── canboatjs.ts │ │ │ ├── canbus.ts │ │ │ ├── execute.test.ts │ │ │ ├── execute.ts │ │ │ ├── filestream.ts │ │ │ ├── folderstream.ts │ │ │ ├── from_json.test.ts │ │ │ ├── from_json.ts │ │ │ ├── gpiod-seatalk.ts │ │ │ ├── gpsd.test.ts │ │ │ ├── gpsd.ts │ │ │ ├── index.ts │ │ │ ├── keys-filter.test.ts │ │ │ ├── keys-filter.ts │ │ │ ├── liner.test.ts │ │ │ ├── liner.ts │ │ │ ├── log.ts │ │ │ ├── logging.test.ts │ │ │ ├── logging.ts │ │ │ ├── mdns-ws.test.ts │ │ │ ├── mdns-ws.ts │ │ │ ├── multiplexedlog.ts │ │ │ ├── n2k-signalk.test.ts │ │ │ ├── n2k-signalk.ts │ │ │ ├── n2kAnalyzer.ts │ │ │ ├── nmea0183-signalk.test.ts │ │ │ ├── nmea0183-signalk.ts │ │ │ ├── nullprovider.ts │ │ │ ├── pigpio-seatalk.ts │ │ │ ├── replacer.test.ts │ │ │ ├── replacer.ts │ │ │ ├── s3.ts │ │ │ ├── serialport.ts │ │ │ ├── simple.ts │ │ │ ├── splitting-liner.test.ts │ │ │ ├── splitting-liner.ts │ │ │ ├── tcp.test.ts │ │ │ ├── tcp.ts │ │ │ ├── tcpserver.ts │ │ │ ├── test-helpers.ts │ │ │ ├── throttle.ts │ │ │ ├── timestamp-throttle.test.ts │ │ │ ├── timestamp-throttle.ts │ │ │ ├── types.ts │ │ │ ├── udp.test.ts │ │ │ ├── udp.ts │ │ │ └── vendor.d.ts │ │ └── tsconfig.json │ └── typedoc-theme/ │ ├── .gitignore │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── SignalKTheme.tsx │ │ ├── SignalKThemeContext.tsx │ │ ├── assets/ │ │ │ └── theme.css │ │ ├── index.tsx │ │ └── partials/ │ │ └── toolbar.tsx │ └── tsconfig.json ├── public/ │ └── examples/ │ ├── http-example.html │ ├── index.html │ └── loginform.html ├── releasing.md ├── samples/ │ ├── aava-n2k.data │ ├── gofree-merrimac.log │ ├── gps.log │ ├── n2kd-183-merrimac.log │ ├── nais300-merrimac.log │ ├── nais400-merrimac.log │ └── plaka.log ├── settings/ │ ├── actisense-serial-settings.json │ ├── commandline-provider-settings.json │ ├── defaults.json-sample │ ├── multiple-sources.json │ ├── multiplexed.json │ ├── n2k-from-file-settings.json │ ├── signalk-ws-settings.json │ ├── simulator.json │ ├── volare-file-settings-filtered.json │ ├── volare-file-settings.json │ ├── volare-gpsd-settings.json │ ├── volare-serial-settings.json │ ├── volare-tcp-settings.json │ └── volare-udp-settings.json ├── src/ │ ├── @types/ │ │ ├── api-schema-builder.d.ts │ │ ├── primus.d.ts │ │ └── signalk_signalk-schema.d.ts │ ├── BackpressureManager.ts │ ├── LatestValuesAccumulator.ts │ ├── api/ │ │ ├── apps/ │ │ │ ├── openApi.json │ │ │ └── openApi.ts │ │ ├── autopilot/ │ │ │ ├── asyncApi.ts │ │ │ ├── index.ts │ │ │ └── openApi.ts │ │ ├── course/ │ │ │ ├── asyncApi.ts │ │ │ ├── index.ts │ │ │ └── openApi.ts │ │ ├── discovery/ │ │ │ ├── index.ts │ │ │ └── openApi.ts │ │ ├── history/ │ │ │ ├── index.ts │ │ │ └── openApi.ts │ │ ├── index.ts │ │ ├── notifications/ │ │ │ ├── alarm.ts │ │ │ ├── asyncApi.ts │ │ │ ├── index.ts │ │ │ ├── notificationManager.ts │ │ │ └── openApi.ts │ │ ├── openApiSchemas.ts │ │ ├── radar/ │ │ │ ├── asyncApi.ts │ │ │ ├── index.ts │ │ │ └── openApi.ts │ │ ├── resources/ │ │ │ ├── asyncApi.ts │ │ │ ├── index.ts │ │ │ ├── openApi.ts │ │ │ └── validate.ts │ │ ├── security/ │ │ │ ├── openApi.json │ │ │ └── openApi.ts │ │ ├── streams/ │ │ │ ├── binary-stream-manager.ts │ │ │ └── index.ts │ │ ├── swagger.ts │ │ └── weather/ │ │ ├── index.ts │ │ └── openApi.ts │ ├── app.ts │ ├── atomicWrite.ts │ ├── baconjs-compat.ts │ ├── categories.ts │ ├── config/ │ │ ├── config.test.js │ │ ├── config.ts │ │ ├── development.js │ │ ├── get.js │ │ └── production.js │ ├── constants.ts │ ├── cors.ts │ ├── debug.ts │ ├── deltaPriority.ts │ ├── deltacache.ts │ ├── deltachain.ts │ ├── deltaeditor.ts │ ├── deltastats.ts │ ├── discovery.js │ ├── dummysecurity.ts │ ├── events.ts │ ├── index.ts │ ├── interfaces/ │ │ ├── applicationData.js │ │ ├── appstore.js │ │ ├── index.js │ │ ├── logfiles.js │ │ ├── mfd_webapp.ts │ │ ├── nmea-tcp.ts │ │ ├── playground.js │ │ ├── plugins.ts │ │ ├── providers.ts │ │ ├── rest.js │ │ ├── tcp.ts │ │ ├── unitpreferences-api.js │ │ ├── wasm.ts │ │ ├── webapps.js │ │ └── ws.ts │ ├── logging.js │ ├── login-rate-limiter.ts │ ├── mdns.js │ ├── modules.ts │ ├── oidc/ │ │ ├── authorization.ts │ │ ├── config.ts │ │ ├── discovery.ts │ │ ├── id-token-validation.ts │ │ ├── index.ts │ │ ├── oidc-admin.ts │ │ ├── oidc-auth.ts │ │ ├── permission-mapping.ts │ │ ├── pkce.ts │ │ ├── state.ts │ │ ├── token-exchange.ts │ │ ├── types.ts │ │ └── user-info.ts │ ├── pipedproviders.ts │ ├── plugin-paths.ts │ ├── pluginid.ts │ ├── ports.ts │ ├── put.ts │ ├── redirects.json │ ├── requestResponse.ts │ ├── security.ts │ ├── serialports.ts │ ├── serverroutes.ts │ ├── serverstate/ │ │ └── store.ts │ ├── streambundle.ts │ ├── subscriptionmanager.ts │ ├── tokensecurity.ts │ ├── types.ts │ ├── unitpreferences/ │ │ ├── index.ts │ │ ├── loader.ts │ │ ├── resolver.ts │ │ └── types.ts │ ├── version.ts │ ├── wasm/ │ │ ├── bindings/ │ │ │ ├── binary-stream.ts │ │ │ ├── env-imports.ts │ │ │ ├── index.ts │ │ │ ├── radar-provider.ts │ │ │ ├── resource-provider.ts │ │ │ ├── socket-manager.ts │ │ │ └── weather-provider.ts │ │ ├── index.ts │ │ ├── loader/ │ │ │ ├── index.ts │ │ │ ├── plugin-config.ts │ │ │ ├── plugin-lifecycle.ts │ │ │ ├── plugin-registry.ts │ │ │ ├── plugin-routes.ts │ │ │ └── types.ts │ │ ├── loaders/ │ │ │ ├── index.ts │ │ │ └── standard-loader.ts │ │ ├── types.ts │ │ ├── utils/ │ │ │ ├── fetch-wrapper.ts │ │ │ ├── format-detection.ts │ │ │ └── index.ts │ │ ├── wasm-runtime.ts │ │ ├── wasm-serverapi.ts │ │ ├── wasm-storage.ts │ │ └── wasm-subscriptions.ts │ ├── zip.ts │ └── zones.ts ├── test/ │ ├── BackpressureManager.ts │ ├── LatestValuesAccumulator.js │ ├── acls.js │ ├── applicationData.ts │ ├── chart-tile-regex.ts │ ├── course.ts │ ├── delete.js │ ├── deltaPriority.ts │ ├── deltacache.js │ ├── endpoint-auth.ts │ ├── error-logging.ts │ ├── externalssl.ts │ ├── filter-test-helper.ts │ ├── history-api.ts │ ├── history.js │ ├── httpprovider.js │ ├── metadata-e2e.ts │ ├── metadata.js │ ├── modules.js │ ├── multiple-values.js │ ├── nmea0183-filtering.ts │ ├── notifications.ts │ ├── oidc/ │ │ ├── authorization.test.ts │ │ ├── config.test.ts │ │ ├── crypto-service.test.ts │ │ ├── discovery.test.ts │ │ ├── id-token-validation.test.ts │ │ ├── integration.test.ts │ │ ├── oidc-auth.test.ts │ │ ├── permission-mapping.test.ts │ │ ├── pkce.test.ts │ │ ├── settings-api.test.ts │ │ ├── state.test.ts │ │ ├── token-exchange.test.ts │ │ ├── user-info.test.ts │ │ ├── user-service.test.ts │ │ └── userinfo-validation.test.ts │ ├── plugin-crash-isolation.ts │ ├── plugin-test-config/ │ │ └── package.json │ ├── plugins.js │ ├── providers.js │ ├── put.js │ ├── rate-limit.ts │ ├── resources.ts │ ├── scripts/ │ │ ├── mock-systemctl │ │ └── signalk-server-setup │ ├── seatalk1-filtering.ts │ ├── security.js │ ├── server-test-config/ │ │ ├── .npmrc │ │ └── package.json │ ├── servertestutilities.js │ ├── sliding-session.ts │ ├── ssl.ts │ ├── staticData.js │ ├── subscriptions.js │ ├── ts-servertestutilities.ts │ ├── unitpreferences.ts │ ├── wasm-plugin-test-config/ │ │ └── package.json │ ├── wasm-plugins.ts │ ├── ws-connection-limit.ts │ └── zones.ts ├── test-server-as-include/ │ ├── package.json │ ├── run.sh │ └── works-as-include.js ├── tools/ │ ├── README.md │ ├── oidc-test-env/ │ │ ├── README.md │ │ ├── authelia/ │ │ │ ├── configuration.yml │ │ │ └── users_database.yml │ │ ├── docker-compose.yml │ │ └── traefik/ │ │ ├── dynamic.yml │ │ └── traefik.yml │ ├── test-auth-negative.sh │ ├── test-oidc-all.sh │ ├── test-oidc-flow.sh │ └── test-oidc-sso.sh ├── tsconfig.base.json ├── tsconfig.json ├── typedoc.json ├── unitpreferences/ │ ├── README.md │ ├── categories.json │ ├── config.json │ ├── custom-categories.json │ ├── default-categories.json │ ├── presets/ │ │ ├── imperial-uk.json │ │ ├── imperial-us.json │ │ ├── metric.json │ │ ├── nautical-imperial-uk.json │ │ ├── nautical-imperial-us.json │ │ └── nautical-metric.json │ ├── primary-categories.json │ └── standard-units-definitions.json └── util/ └── start-stop.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coderabbit.yaml ================================================ language: en-US reviews: profile: assertive auto_review: enabled: true drafts: false high_level_summary_instructions: | Write all summaries in present tense. Describe what the code does, not what it did. Example: "This PR adds feature X" instead of "This PR added feature X" path_instructions: - path: '**/*' instructions: | ## Contribution guidelines compliance Check that the PR follows the SignalK contribution guidelines: https://github.com/SignalK/signalk-server/blob/master/CONTRIBUTING.md Flag any deviation in PR structure, commit message format, or documentation requirements. ## Echo comments Flag any comment that merely restates what the code already says. Examples of echo comments to flag: - `// Sets the age` above a function named `setAge()` - `// Loop through items` above a `for` loop These add noise without adding meaning. Request removal or replacement with a comment explaining *why*, not *what*. ## Leftover crumbs from intermediate commits Check for references to things that existed in earlier commits of this PR but are no longer present — removed variables, old function names, deleted files, superseded approaches. These are confusing to future readers. Flag any comments, docs, or code that refer to something not present in the current state of the branch. ## Documentation drift risk Flag any .md file that contains detailed implementation steps, specific API call sequences, code snippets, or configuration values that are likely to fall out of sync as the code evolves. Documentation should describe architecture and how things work conceptually — not step-by-step instructions that duplicate or shadow the code itself. ## Unchecked items in test plans If a PR description or any .md file contains a checklist with unchecked items, flag it. Either the work is incomplete, or the checklist should be removed before merge. Do not let unchecked boxes pass silently. ## Implementation status in documentation Flag any .md file that describes implementation progress, status, or build steps (e.g. "Step 3: implement X", "TODO: add Y", "currently implemented as Z"). This belongs in PR descriptions or commit messages, not in documentation. Documentation should describe how things work, not how they were built or what stage they are in. Architecture decisions and design rationale are fine. Build narratives are not. ## What NOT to flag Do not flag the following patterns — they are intentional: - Constant names that are descriptive in their own context (e.g. DEFAULT_PULL_TIMEOUT) — do not suggest renames for clarity. - Test cleanup using Object.assign(process.env, original) — this is sufficient when the test only reads from process.env. - Test assertions that verify array length and structure rather than exact element order — Object property iteration order is guaranteed for string keys in modern JS. - Smoke tests that verify a method does not throw without asserting on internal state — exposing internals for testing is worse. - Callbacks typed as functions that only work in-process — do not suggest REST/WebSocket alternatives unless specifically requested. The limitation is obvious from the type signature. ## Scope Focus on files changed since master. Think carefully about how changes interact with existing code and documentation — not just the diff in isolation. ================================================ FILE: .dockerignore ================================================ node_modules packages work ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true ================================================ FILE: .gitattributes ================================================ # Default: normalize line endings to LF in the repo, auto-detect on checkout * text=auto eol=lf # Force LF for files that must never be CRLF *.sh text eol=lf *.bash text eol=lf *.json text eol=lf *.js text eol=lf *.ts text eol=lf *.yml text eol=lf *.yaml text eol=lf *.md text eol=lf *.conf text eol=lf Dockerfile text eol=lf # Binary files — do not normalize *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.woff binary *.woff2 binary *.ttf binary *.eot binary ================================================ FILE: .github/FUNDING.yml ================================================ github: [sbender9, tkurki] ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Enable version updates for npm - package-ecosystem: 'npm' # Look for `package.json` and `lock` files in the `root` directory directory: '/' # Check the npm registry for updates every day (weekdays) schedule: interval: 'monthly' open-pull-requests-limit: 5 # Enable version updates for Docker - package-ecosystem: 'docker' # Look for a `Dockerfile` in the `root` directory directory: '/' # Check for updates once a week schedule: interval: 'monthly' # Enable version updates for GitHub Actions - package-ecosystem: 'github-actions' directory: '/' schedule: interval: 'monthly' ================================================ FILE: .github/workflows/build-base-image.yml ================================================ name: Build Docker base images on: schedule: - cron: '0 0 * * 1' workflow_dispatch: jobs: build-images: if: github.repository == 'SignalK/signalk-server' name: Ubuntu base image strategy: matrix: os: [24.04, alpine] node: [24.x] vm: [ubuntu-latest, ubuntu-24.04-arm] include: - vm: ubuntu-latest arch: amd os: 24.04 node: 24.x node_safe: 24.x platform: linux/amd64 - vm: ubuntu-latest arch: amd os: alpine node: 24.x node_safe: 24 platform: linux/amd64 - vm: ubuntu-24.04-arm arch: arm os: 24.04 node: 24.x node_safe: 24.x platform: linux/arm64 - vm: ubuntu-24.04-arm arch: arm os: alpine node: 24.x node_safe: 24 platform: linux/arm64 runs-on: ${{ matrix.vm }} steps: - uses: actions/checkout@v6 - uses: docker/setup-buildx-action@v4 - name: Login to ghcr.io uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT }} - name: Build Ubuntu baseimages uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile_base_${{ matrix.os }} platforms: ${{ matrix.platform }} push: true tags: | ghcr.io/signalk/signalk-server-base:${{ matrix.arch }}-${{ matrix.os }}-${{ matrix.node_safe }} build-args: | NODE=${{ matrix.node_safe }} create-and-push-manifest: needs: [build-images] runs-on: ubuntu-latest strategy: matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x node_safe: 24.x - os: alpine node: 24.x node_safe: 24 steps: - name: Login to ghcr.io uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT }} - name: Create and push multi-arch manifest to GHCR uses: int128/docker-manifest-create-action@v2 with: tags: | ghcr.io/signalk/signalk-server-base:latest-${{ matrix.os }}-${{ matrix.node_safe }} sources: | ghcr.io/signalk/signalk-server-base:amd-${{ matrix.os }}-${{ matrix.node_safe }} ghcr.io/signalk/signalk-server-base:arm-${{ matrix.os }}-${{ matrix.node_safe }} copy-to-dockerhub: needs: create-and-push-manifest runs-on: ubuntu-latest strategy: matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x node_safe: 24.x - os: alpine node: 24.x node_safe: 24 steps: - name: Install skopeo run: | sudo apt-get update sudo apt-get install -y skopeo - name: Copy images from GHCR to Docker Hub shell: bash env: GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GHCR_PAT }} DOCKER_HUB_USERNAME: signalkci DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | set -euo pipefail skopeo copy --all \ --src-creds "${GHCR_USERNAME}:${GHCR_TOKEN}" \ --dest-creds "${DOCKER_HUB_USERNAME}:${DOCKER_HUB_ACCESS_TOKEN}" \ "docker://ghcr.io/signalk/signalk-server-base:latest-${{ matrix.os }}-${{ matrix.node_safe }}" \ "docker://docker.io/signalk/signalk-server-base:latest-${{ matrix.os }}-${{ matrix.node_safe }}" housekeeping: needs: [copy-to-dockerhub] runs-on: ubuntu-latest permissions: packages: write steps: - name: Wait for GHCR indexing run: sleep 60 - name: Remove Docker Image from GHCR continue-on-error: true uses: dataaxiom/ghcr-cleanup-action@v1 with: packages: signalk-server-base delete-untagged: true delete-tags: | amd-*,arm-* token: ${{ secrets.GHCR_PAT }} # Need to have delete permission ================================================ FILE: .github/workflows/build-docker.yml ================================================ name: Build Docker development container concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: branches: - master - 'build-docker' tags: - '*' - '!v*' workflow_dispatch: jobs: signalk-server_npm_files: if: github.repository == 'SignalK/signalk-server' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Node setup uses: actions/setup-node@v6 with: node-version: '24.x' - name: Build npm files locally and upload artifacts run: | npm cache clean -f npm install npm@latest -g npm install --package-lock-only npm ci && npm cache clean --force npm run build:all npm pack --workspaces rm signalk-typedoc-signalk-theme*.tgz # This is only needed as a dev dependency npm pack - name: Upload artifacts uses: actions/upload-artifact@v7 with: retention-days: 1 name: packed-modules path: | *.tgz docker_images: needs: signalk-server_npm_files strategy: matrix: os: [24.04, alpine] node: [24.x] vm: [ubuntu-latest, ubuntu-24.04-arm] include: - vm: ubuntu-latest arch: amd platform: linux/amd64 - vm: ubuntu-24.04-arm arch: arm node: 24.x platform: linux/arm64 - os: 24.04 node: 24.x node_safe: 24.x - os: alpine node: 24.x node_safe: 24 runs-on: ${{ matrix.vm }} steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to ghcr.io if: github.event_name != 'pull_request' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT }} - uses: actions/download-artifact@v8 with: name: packed-modules - name: Build and push uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile platforms: ${{ matrix.platform }} push: true tags: ghcr.io/signalk/signalk-server:${{ matrix.arch }}-${{ matrix.os }}-${{ matrix.node_safe }}-${{ github.run_id }} build-args: | REGISTRY=ghcr.io BASE_IMAGE=${{ matrix.os }}-${{ matrix.node_safe }} create-and-push-manifest: needs: docker_images runs-on: ubuntu-latest strategy: matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x suffix: -24.x node_safe: 24.x - os: alpine node: 24.x suffix: -24-alpine node_safe: 24 steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Docker meta id: docker_meta uses: docker/metadata-action@v6 with: images: | ghcr.io/signalk/signalk-server tags: | type=ref,event=branch type=sha flavor: | suffix=${{ matrix.suffix }} - name: Login to ghcr.io uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT }} - name: Create and push multi-arch manifest to GHCR uses: int128/docker-manifest-create-action@v2 with: tags: | ${{ steps.docker_meta.outputs.tags }} sources: | ghcr.io/signalk/signalk-server:amd-${{ matrix.os }}-${{ matrix.node_safe }}-${{ github.run_id }} ghcr.io/signalk/signalk-server:arm-${{ matrix.os }}-${{ matrix.node_safe }}-${{ github.run_id }} - name: Save tags to file run: | mkdir -p /tmp/tags echo "${{ steps.docker_meta.outputs.tags }}" > /tmp/tags/${{ matrix.node_safe }}.txt - name: Upload tag artifact uses: actions/upload-artifact@v7 with: name: ubuntu-tag-${{ matrix.node_safe }} path: /tmp/tags/${{ matrix.node_safe }}.txt retention-days: 1 copy-to-dockerhub: needs: create-and-push-manifest runs-on: ubuntu-latest strategy: matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x node_safe: 24.x - os: alpine node: 24.x node_safe: 24 steps: - name: Download tag artifact uses: actions/download-artifact@v8 with: name: ubuntu-tag-${{ matrix.node_safe }} path: /tmp/tags - name: Install skopeo run: | sudo apt-get update sudo apt-get install -y skopeo - name: Copy images from GHCR to Docker Hub shell: bash env: GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GHCR_PAT }} DOCKER_HUB_USERNAME: signalkci DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | set -euo pipefail TAGS_FILE="/tmp/tags/${{ matrix.node_safe }}.txt" while IFS= read -r FULL_TAG || [ -n "${FULL_TAG:-}" ]; do [ -z "${FULL_TAG:-}" ] && continue TAG="${FULL_TAG##*:}" echo "Copying: ${FULL_TAG} -> signalk/signalk-server:${TAG}" skopeo copy --all \ --src-creds "${GHCR_USERNAME}:${GHCR_TOKEN}" \ --dest-creds "${DOCKER_HUB_USERNAME}:${DOCKER_HUB_ACCESS_TOKEN}" \ "docker://${FULL_TAG}" \ "docker://docker.io/signalk/signalk-server:${TAG}" done < "$TAGS_FILE" record-image-sizes: needs: create-and-push-manifest runs-on: ubuntu-latest steps: - name: Checkout metrics branch uses: actions/checkout@v6 with: ref: metrics - name: Record image sizes env: GHCR_CREDS: '${{ github.actor }}:${{ secrets.GHCR_PAT }}' run: | set -euo pipefail TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) COMMIT="${{ github.sha }}" CSV="master-container_sizes.csv" if [ ! -f "$CSV" ]; then echo "timestamp,commit,tag,size_bytes" > "$CSV" fi REGISTRY="ghcr.io/signalk/signalk-server" RUN_ID="${{ github.run_id }}" for TAG in \ "amd-24.04-24.x-${RUN_ID}" \ "arm-24.04-24.x-${RUN_ID}" \ "amd-alpine-24-${RUN_ID}" \ "arm-alpine-24-${RUN_ID}"; do DIGEST=$(skopeo inspect --raw --creds "${GHCR_CREDS}" \ "docker://${REGISTRY}:${TAG}" | \ jq -r '.manifests[0].digest') SIZE=$(skopeo inspect --raw --creds "${GHCR_CREDS}" \ "docker://${REGISTRY}@${DIGEST}" | \ jq '[.layers[].size] | add') echo "${TIMESTAMP},${COMMIT},${TAG%-${RUN_ID}},${SIZE}" >> "$CSV" done - name: Commit and push run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add master-container_sizes.csv git commit -m "chore: record container sizes for ${GITHUB_SHA::7}" git push housekeeping: needs: [copy-to-dockerhub, record-image-sizes] runs-on: ubuntu-latest permissions: packages: write steps: - name: Wait for GHCR indexing run: sleep 60 - name: Remove Temporary & Untagged Docker Images from GHCR continue-on-error: true uses: dataaxiom/ghcr-cleanup-action@v1 with: packages: signalk-server delete-untagged: true delete-tags: | *-${{ github.run_id }} token: ${{ secrets.GHCR_PAT }} ================================================ FILE: .github/workflows/plugin-ci.yml ================================================ # SignalK Plugin CI - Reusable Workflow # # This workflow lives in the SignalK/signalk-server repository. # Plugin developers reference it with a single line in their own workflow. # # Platforms tested: # - Linux x64 (Node 22, 24) # - Linux arm64 (Node 22, 24) — Raspberry Pi 4/5 # - macOS (Node 22, 24) # - Windows (Node 22, 24) # - armv7/armhf (Node 20 — matches Venus OS 3.70 on Cerbo GX) # Runs under QEMU emulation with python3, make, g++ # # Usage in your plugin repo: # See docs/develop/plugins/examples/plugin-caller-example.yml name: SignalK Plugin CI on: workflow_call: inputs: # ── Test configuration ────────────────────────────────── test-command: description: 'Command to run tests (default: npm test)' type: string default: 'npm test' build-command: description: 'Command to build the plugin (default: npm run build --if-present)' type: string default: 'npm run build --if-present' format-check-command: description: 'Command to verify formatting (e.g. "npm run prettier:check" or "npx biome check ."). Blocking when set, skipped when empty.' type: string default: '' coverage-command: description: 'Command that runs tests with coverage (e.g. "npm run coverage"). When set, replaces the standard test run and its output is appended to the job step summary.' type: string default: '' node-versions: description: 'JSON array of Node versions for desktop platforms (default: ["22", "24"])' type: string default: '["22", "24"]' # ── armv7 / Cerbo GX options ──────────────────────────── enable-armv7: description: 'Run armv7 (Cerbo GX) tests via QEMU emulation' type: boolean default: true # ── SignalK integration test ──────────────────────────── enable-signalk-integration: description: 'Start a SignalK server and install the plugin for integration testing' type: boolean default: false signalk-server-versions: description: 'JSON array of signalk-server versions to fan the integration test out over (e.g. ''["2.23.0", "latest"]'')' type: string default: '["latest"]' # Least-privilege token for the jobs in this reusable workflow. We only # read the caller's repository — install, build, and test steps never # need to write to GitHub resources. permissions: contents: read jobs: # ════════════════════════════════════════════════════════════ # Desktop platforms matrix: Linux x64, Linux arm64, macOS, Windows # ════════════════════════════════════════════════════════════ desktop: name: >- ${{ matrix.os == 'ubuntu-latest' && 'Linux' || matrix.os == 'ubuntu-24.04-arm' && 'Linux arm64' || matrix.os == 'macos-latest' && 'macOS' || matrix.os == 'windows-latest' && 'Windows' || matrix.os }} / Node ${{ matrix.node }} runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: fail-fast: false matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] node: ${{ fromJson(inputs.node-versions) }} steps: - name: Checkout plugin uses: actions/checkout@v6 - name: Setup Node.js ${{ matrix.node }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} cache: ${{ hashFiles('**/package-lock.json') != '' && 'npm' || '' }} - name: Validate plugin package.json id: validate-pkg shell: bash run: | cat << 'VALIDATE' > /tmp/validate-pkg.js const pkg = require(process.cwd() + '/package.json'); const fs = require('fs'); const path = require('path'); let errors = []; let warnings = []; // Required: signalk-node-server-plugin keyword if (!pkg.keywords || !pkg.keywords.includes('signalk-node-server-plugin')) { errors.push('Missing required keyword: signalk-node-server-plugin'); } // Required: main or exports field if (!pkg.main && !pkg.exports) { errors.push('Missing main or exports field'); } // Version must be valid semver — npm normalizes invalid versions in // the registry but the installed package.json retains the original, // which breaks semver comparisons in the appstore const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; if (!pkg.version) { errors.push('Missing "version" field in package.json'); } else if (!SEMVER_RE.test(pkg.version)) { errors.push('Invalid semver version "' + pkg.version + '" in package.json — see https://semver.org'); } // Scan source files for hardcoded home directory paths const srcFiles = []; function collectFiles(dir, exts) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === '.git') continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { collectFiles(full, exts); continue; } if (exts.some(e => entry.name.endsWith(e))) srcFiles.push(full); } } collectFiles('.', ['.js', '.ts', '.mjs', '.cjs', '.sh']); const hardcodedPathRe = /["'`]\/home\/[a-zA-Z][a-zA-Z0-9_-]*\//g; for (const file of srcFiles) { const content = fs.readFileSync(file, 'utf8'); const matches = content.match(hardcodedPathRe); if (matches) { const relFile = path.relative('.', file); errors.push(relFile + ' contains hardcoded home directory path: ' + matches[0]); } } // Warn about install-time scripts that won't run under the App // Store's --ignore-scripts install. 'prepare' is intentionally // excluded — it's the standard TypeScript publish-build hook. const riskyScripts = ['preinstall', 'postinstall', 'install']; for (const s of riskyScripts) { if (pkg.scripts && pkg.scripts[s]) { warnings.push('package.json has a "' + s + '" script: ' + pkg.scripts[s]); warnings.push('Note: SignalK App Store uses --ignore-scripts, so "' + s + '" will NOT run on install'); } } // engines.node if (!pkg.engines || !pkg.engines.node) { warnings.push('No "engines.node" field in package.json — plugins should declare their minimum Node.js version'); } // ESM-only plugins can't be loaded by the server's require() call if (pkg.type === 'module') { warnings.push('package.json has "type": "module" — SignalK server loads plugins with require(). ESM-only plugins may fail to load.'); } // baconjs is provided by the server — plugins must not bundle their own copy if (pkg.dependencies && pkg.dependencies.baconjs) { const baconRange = pkg.dependencies.baconjs; // Accept shorthand ranges ("^3", "3", "3.x") as well as full semver. // Only warn when we confidently parse a leading major < 3 — dist-tags, // git specs, and other non-numeric ranges are left alone. const baconMatch = baconRange.match(/^\s*[~^]?(\d+)(?:[.\s]|$)/); if (baconMatch) { const baconMajor = parseInt(baconMatch[1], 10); if (baconMajor < 3) { warnings.push( 'Plugin bundles baconjs ' + baconRange + ' in dependencies. ' + 'The Signal K server provides baconjs >= 3.x and forces all plugins to use its copy. ' + 'Bundling an older version will be ignored at runtime and wastes install size. ' + 'Remove baconjs from dependencies or update to ^3.0.0 and use the server-provided instance' ); } } } // React 19 required for plugins with embedded webapps (Module Federation) const webappKeywords = ['signalk-plugin-configurator', 'signalk-embeddable-webapp', 'signalk-webapp']; const matchedWebappKeywords = pkg.keywords ? webappKeywords.filter(kw => pkg.keywords.includes(kw)) : []; if (matchedWebappKeywords.length > 0) { const allDeps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }; const reactRange = allDeps.react; if (reactRange) { // Accept shorthand ranges ("^18", "18", "18.x") as well as full semver. // Matches the baconjs check above — only warn when a leading major // parses confidently. Dist-tags and git specs are left alone. const versionMatch = reactRange.match(/^\s*[~^]?(\d+)(?:[.\s]|$)/); if (versionMatch) { const major = parseInt(versionMatch[1], 10); if (major < 19) { warnings.push( 'Plugin has webapp keyword (' + matchedWebappKeywords.join(', ') + ') but declares react ' + reactRange + ' (major version ' + major + '). ' + 'The Signal K admin UI requires React >= 19 for Module Federation compatibility. ' + 'Update your react and react-dom dependencies to ^19.0.0' ); } } } } warnings.forEach(w => console.log('::warning::' + w)); if (errors.length > 0) { errors.forEach(e => console.log('::error::' + e)); process.exit(1); } console.log('Plugin package.json validation passed'); VALIDATE node /tmp/validate-pkg.js - name: Install dependencies run: | if [ -f package-lock.json ]; then npm ci else echo "No package-lock.json found — using npm install instead of npm ci" npm install fi shell: bash - name: Build env: BUILD_CMD: ${{ inputs.build-command }} shell: bash run: eval "$BUILD_CMD" - name: Validate plugin entry point id: validate-entry shell: bash timeout-minutes: 2 run: | cat << 'ENTRYPOINT' > /tmp/validate-entry.js const path = require('path'); const pkg = require(process.cwd() + '/package.json'); // Resolve the plugin's CommonJS entry point, honoring conditional // exports like { ".": { "require": "./cjs/index.cjs" } } that // modern TypeScript templates emit. Falls back to 'index.js'. function resolveEntry(p, fallback) { if (p && p.main) return p.main; const e = p && p.exports; if (typeof e === 'string') return e; if (e && typeof e === 'object') { const root = typeof e['.'] !== 'undefined' ? e['.'] : e; if (typeof root === 'string') return root; if (root && typeof root === 'object') { for (const k of ['require', 'node', 'default', 'import']) { if (typeof root[k] === 'string') return root[k]; } } } return fallback; } const entry = resolveEntry(pkg, 'index.js'); try { const mod = require(path.resolve(entry)); const pluginConstructor = mod.default || mod; if (typeof pluginConstructor !== 'function') { console.log('::error::Plugin entry point does not export a function (got ' + typeof pluginConstructor + ')'); process.exit(1); } console.log('Plugin exports a valid constructor function (' + entry + ')'); } catch (e) { console.log('::error::Failed to load plugin entry point (' + entry + '): ' + e.message); process.exit(1); } process.exit(0); ENTRYPOINT node /tmp/validate-entry.js - name: Validate plugin.schema() if defined id: validate-schema shell: bash timeout-minutes: 2 run: | cat << 'SCHEMACHECK' > /tmp/check-schema.js const path = require('path'); const pkg = require(process.cwd() + '/package.json'); // See validate-entry.js for the rationale — object/conditional // exports must be resolved, not flattened to 'index.js'. function resolveEntry(p, fallback) { if (p && p.main) return p.main; const e = p && p.exports; if (typeof e === 'string') return e; if (e && typeof e === 'object') { const root = typeof e['.'] !== 'undefined' ? e['.'] : e; if (typeof root === 'string') return root; if (root && typeof root === 'object') { for (const k of ['require', 'node', 'default', 'import']) { if (typeof root[k] === 'string') return root[k]; } } } return fallback; } const entry = resolveEntry(pkg, 'index.js'); let mod; try { mod = require(path.resolve(entry)); } catch (e) { // Entry point already validated in previous step — skip schema check console.log('Could not load plugin — skipping schema check'); process.exit(0); } const ctor = mod.default || mod; if (typeof ctor !== 'function') { process.exit(0); } // Minimal mock — only needs to survive the constructor const mockApp = { config: { configPath: '/tmp', version: '0.0.0' }, selfContext: 'vessels.urn:mrn:signalk:uuid:test', getPath: () => '', getSelfPath: () => undefined, getMetadata: () => undefined, getDataDirPath: () => '/tmp', readPluginOptions: () => ({}), savePluginOptions: (config, cb) => { if (cb) cb(null); }, handleMessage: () => {}, debug: () => {}, error: () => {}, setPluginStatus: () => {}, setPluginError: () => {}, reportError: () => {}, on: () => ({ unsubscribe: () => {} }), registerPutHandler: () => {}, registerDeltaInputHandler: () => () => {}, subscriptionmanager: { subscribe: () => ({ unsubscribe: () => {} }) }, streambundle: { getSelfBus: () => ({ onValue: () => ({ unsubscribe: () => {} }) }) }, signalk: { self: 'vessels.urn:mrn:signalk:uuid:test', retrieve: () => ({}) } }; let plugin; try { plugin = ctor(mockApp); } catch (e) { // Constructor needs a real app — we test lifecycle separately console.log('Plugin constructor needs server context — skipping schema check'); process.exit(0); } // plugin.schema can be a function or a plain object (both are valid) if (!plugin || (typeof plugin.schema !== 'function' && typeof plugin.schema !== 'object') || plugin.schema === null) { console.log('Plugin does not define schema — nothing to check'); process.exit(0); } const schemaIsFunction = typeof plugin.schema === 'function'; const label = schemaIsFunction ? 'plugin.schema()' : 'plugin.schema'; let schema; if (schemaIsFunction) { // A throwing schema() crashes the server's plugin config UI try { schema = plugin.schema(); } catch (e) { console.log('::error::' + label + ' threw an error: ' + e.message); console.log('::error::This will crash the SignalK server when it tries to render the plugin config UI.'); process.exit(1); } } else { schema = plugin.schema; } // Validate it returned something useful if (schema === null || schema === undefined) { console.log('::warning::' + label + ' returned ' + schema + ' — consider returning a valid JSON Schema object'); process.exit(0); } if (typeof schema !== 'object') { console.log('::error::' + label + ' returned ' + typeof schema + ' instead of a JSON Schema object'); process.exit(1); } // Basic JSON Schema structure check (rjsf compatibility) if (!schema.type && !schema.properties && !schema.oneOf && !schema.anyOf) { console.log('::warning::' + label + ' has no type, properties, oneOf, or anyOf — is this a valid JSON Schema?'); } // Check for JSON-hostile values that would be silently dropped or // replaced by JSON.stringify. Circular refs throw from stringify; // functions/symbols/undefined properties do not — walk the tree // explicitly so authors get a useful error instead of data loss. const jsonIssues = []; const seen = new WeakSet(); function walk(node, path) { if (node === null) return; const t = typeof node; if (t === 'function' || t === 'symbol') { jsonIssues.push(path + ' is a ' + t + ' — JSON.stringify will drop it'); return; } if (t !== 'object') return; if (seen.has(node)) { jsonIssues.push(path + ' is a circular reference'); return; } seen.add(node); if (Array.isArray(node)) { node.forEach((v, i) => walk(v, path + '[' + i + ']')); return; } for (const k of Object.keys(node)) { const v = node[k]; if (v === undefined) { jsonIssues.push(path + '.' + k + ' is undefined — JSON.stringify will drop this property'); continue; } walk(v, path + '.' + k); } } walk(schema, 'schema'); if (jsonIssues.length > 0) { jsonIssues.forEach(m => console.log('::error::' + label + ': ' + m)); console.log('::error::The server stores the schema as JSON — non-serializable members cause silent data loss.'); process.exit(1); } console.log(label + ' is a valid JSON Schema object'); process.exit(0); SCHEMACHECK node /tmp/check-schema.js - name: Test plugin stop()/start() lifecycle id: lifecycle-check shell: bash timeout-minutes: 2 run: | cat << 'LIFECYCLE' > /tmp/check-lifecycle.js const path = require('path'); const pkg = require(process.cwd() + '/package.json'); // See validate-entry.js for the rationale — object/conditional // exports must be resolved, not flattened to 'index.js'. function resolveEntry(p, fallback) { if (p && p.main) return p.main; const e = p && p.exports; if (typeof e === 'string') return e; if (e && typeof e === 'object') { const root = typeof e['.'] !== 'undefined' ? e['.'] : e; if (typeof root === 'string') return root; if (root && typeof root === 'object') { for (const k of ['require', 'node', 'default', 'import']) { if (typeof root[k] === 'string') return root[k]; } } } return fallback; } const entry = resolveEntry(pkg, 'index.js'); let mod; try { mod = require(path.resolve(entry)); } catch (e) { console.log('Could not load plugin — skipping lifecycle check'); process.exit(0); } const ctor = mod.default || mod; if (typeof ctor !== 'function') { process.exit(0); } // ── Delta validation helpers ────────────────────────── const deltaWarnings = []; const deltaErrors = []; // Each dot-separated segment must start with a letter and may contain // letters, digits, or hyphens — hyphens appear in plugin-chosen ids // (e.g. electrical.batteries.house-bank). const pathRe = /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*)*$/; function validateDelta(id, data) { if (!data || typeof data !== 'object') { deltaErrors.push('handleMessage() called with non-object data: ' + typeof data); return; } if (!data.updates) { deltaErrors.push('handleMessage() called without updates field'); return; } if (!Array.isArray(data.updates)) { deltaErrors.push('handleMessage() updates is ' + typeof data.updates + ' — must be an array'); return; } if (data.updates.length === 0) { deltaWarnings.push('handleMessage() called with empty updates array'); return; } for (const update of data.updates) { if (!update || typeof update !== 'object') { deltaErrors.push('updates[] contains non-object: ' + typeof update); continue; } const hasValues = 'values' in update; const hasMeta = 'meta' in update; if (!hasValues && !hasMeta) { deltaErrors.push('update has neither values nor meta — server will silently discard it'); } if (hasValues && !Array.isArray(update.values)) { deltaErrors.push('update.values is ' + typeof update.values + ' — must be an array (server silently discards non-arrays)'); } if (hasMeta && !Array.isArray(update.meta)) { deltaErrors.push('update.meta is ' + typeof update.meta + ' — must be an array'); } const items = [ ...(Array.isArray(update.values) ? update.values : []), ...(Array.isArray(update.meta) ? update.meta : []) ]; for (const item of items) { if (!item || typeof item !== 'object') continue; if (typeof item.path !== 'string') { deltaErrors.push('path is ' + typeof item.path + ' — must be a string'); } else if (item.path !== '' && !pathRe.test(item.path)) { deltaWarnings.push('path "' + item.path + '" has unusual format — expected dotted alphanumeric (e.g. navigation.position)'); } } } } // ── Mock app with delta capture ───────────────────────── const mockApp = { // ── Server configuration ────────────────────────────── config: { configPath: '/tmp', version: '0.0.0' }, selfType: 'uuid', selfId: 'urn:mrn:signalk:uuid:test', selfContext: 'vessels.urn:mrn:signalk:uuid:test', // ── Data model ──────────────────────────────────────── getPath: (p) => undefined, getSelfPath: (p) => undefined, getMetadata: (p) => undefined, handleMessage: (id, data) => validateDelta(id, data), putSelfPath: (aPath, value, cb) => { if (cb) cb({ state: 'COMPLETED', statusCode: 200 }); return Promise.resolve(); }, putPath: (aPath, value, cb) => { if (cb) cb({ state: 'COMPLETED', statusCode: 200 }); return Promise.resolve(); }, queryRequest: () => Promise.resolve({}), streambundle: { getSelfBus: () => ({ onValue: () => ({ unsubscribe: () => {} }) }) }, subscriptionmanager: { subscribe: () => ({ unsubscribe: () => {} }) }, signalk: { self: 'vessels.urn:mrn:signalk:uuid:test', retrieve: () => ({}) }, registerDeltaInputHandler: (handler) => { // Check the handler calls next() with a simple test let nextCalled = false; try { handler({ updates: [{ values: [{ path: 'test.ci.probe', value: 0 }] }] }, (d) => { nextCalled = true; }); } catch (e) { /* handler may need real delta — skip check */ nextCalled = true; } if (!nextCalled) { deltaWarnings.push('registerDeltaInputHandler: handler did not call next() — this will silently drop deltas from all other plugins/sources'); } return () => {}; }, // ── Plugin configuration ────────────────────────────── readPluginOptions: () => ({}), savePluginOptions: (configuration, cb) => { if (cb) cb(null); }, getDataDirPath: () => '/tmp', // ── Status and debugging ────────────────────────────── debug: () => {}, error: () => {}, setPluginStatus: (msg) => console.log(' status: ' + msg), setPluginError: (msg) => console.log(' error: ' + msg), reportError: () => {}, reportOutputMessages: () => {}, // ── PUT / action handlers ───────────────────────────── registerPutHandler: () => {}, registerActionHandler: () => {}, // ── Event system ────────────────────────────────────── on: () => ({ unsubscribe: () => {} }), emit: () => {}, emitPropertyValue: () => {}, onPropertyValues: () => () => {}, // ── Provider registrations (no-op in CI) ────────────── registerResourceProvider: () => {}, registerAutopilotProvider: () => {}, registerWeatherProvider: () => {}, registerHistoryProvider: () => {}, registerHistoryApiProvider: () => {}, // ── API access (no-op stubs) ────────────────────────── getFeatures: () => Promise.resolve({ apis: [], plugins: [] }), getCourse: () => Promise.resolve({}), clearDestination: () => Promise.resolve(), setDestination: () => Promise.resolve(), activateRoute: () => Promise.resolve(), getSerialPorts: () => Promise.resolve({ byId: [], byPath: [], byOpenPlotter: [], serialports: [] }), }; let plugin; try { plugin = ctor(mockApp); } catch (e) { console.log('Plugin constructor requires server context — skipping lifecycle check'); process.exit(0); } if (!plugin || typeof plugin.start !== 'function' || typeof plugin.stop !== 'function') { console.log('Plugin does not have start()/stop() — skipping lifecycle check'); process.exit(0); } // The server calls registerWithRouter() before start() if defined if (typeof plugin.registerWithRouter === 'function') { const noOp = () => mockRouter; const mockRouter = { get: noOp, post: noOp, put: noOp, delete: noOp, patch: noOp, use: noOp, all: noOp, route: noOp, param: noOp }; try { plugin.registerWithRouter(mockRouter); console.log('registerWithRouter() — ok'); } catch (e) { const msg = e.message || String(e); console.log('::warning::registerWithRouter() threw: ' + msg); } } // Detect errors caused by missing CI mock methods vs real plugin bugs function isMockGap(msg) { return /\w+ is not a function/.test(msg) || /Cannot read propert/.test(msg); } // Wrap in an async IIFE so that Promise-returning start()/stop() // hooks surface rejections via await — a sync try/catch around // `plugin.start({})` would silently swallow them. (async () => { try { await Promise.resolve(plugin.start({})); console.log('start({}) — ok'); } catch (e) { const msg = e.message || String(e); console.log('::warning::plugin.start({}) threw: ' + msg); if (isMockGap(msg)) { console.log('::warning::This looks like a missing CI mock method, not a plugin bug. Please report at https://github.com/SignalK/signalk-server/issues'); } else { console.log('::warning::Plugins should handle empty/default configuration gracefully.'); } process.exit(0); } try { await Promise.resolve(plugin.stop()); console.log('stop() — ok'); } catch (e) { const msg = e.message || String(e); if (isMockGap(msg)) { console.log('::warning::plugin.stop() threw: ' + msg); console.log('::warning::This looks like a missing CI mock method, not a plugin bug. Please report at https://github.com/SignalK/signalk-server/issues'); // Don't fail the build for mock gaps } else { console.log('::error::plugin.stop() threw: ' + msg); console.log('::error::This causes the server to leak resources when the plugin is disabled or restarted.'); process.exit(1); } } // Restart: the server calls stop() then start() when config changes try { await Promise.resolve(plugin.start({})); console.log('start({}) again — ok (restart works)'); } catch (e) { const msg = e.message || String(e); if (isMockGap(msg)) { console.log('::warning::plugin.start({}) threw on restart: ' + msg); console.log('::warning::This looks like a missing CI mock method, not a plugin bug. Please report at https://github.com/SignalK/signalk-server/issues'); process.exit(0); } console.log('::error::plugin.start({}) threw on second call: ' + msg); console.log('::error::Plugins must support restart (stop then start). This fails when users toggle the plugin in the server UI.'); process.exit(1); } try { await Promise.resolve(plugin.stop()); } catch (e) { /* ignore */ } // ── Report delta validation results ───────────────────── if (deltaWarnings.length > 0 || deltaErrors.length > 0) { console.log(''); console.log('Delta validation (from deltas emitted during start):'); } deltaWarnings.forEach(w => console.log('::warning::Delta: ' + w)); deltaErrors.forEach(e => console.log('::error::Delta: ' + e)); if (deltaErrors.length > 0) { console.log(''); console.log('Malformed deltas are silently discarded by the server —'); console.log('the plugin appears to work but data never reaches consumers.'); process.exit(1); } console.log('Plugin lifecycle (start/stop/restart) is clean'); process.exit(0); })().catch(e => { console.log('::error::Unexpected error during lifecycle check: ' + (e && e.message || String(e))); process.exit(1); }); LIFECYCLE node /tmp/check-lifecycle.js - name: Scan for deprecated and misused SignalK APIs id: deprecated-api shell: bash run: | cat << 'APISCAN' > /tmp/check-api-usage.js const fs = require('fs'); const path = require('path'); // ── Deprecated APIs (warnings) ────────────────────────── const deprecated = [ { pattern: /\bsetProviderStatus\s*\(/g, message: 'setProviderStatus() is deprecated — use setPluginStatus() instead', }, { pattern: /\bsetProviderError\s*\(/g, message: 'setProviderError() is deprecated — use setPluginError() instead', }, ]; // ── API misuse (errors) ───────────────────────────────── // These access internal server properties not exposed to plugins const misuse = [ { pattern: /\bapp\.server\b/g, message: 'app.server is an internal property — not part of the plugin API', }, { pattern: /\bapp\.deltaCache\b/g, message: 'app.deltaCache is internal — use app.getPath() instead', }, { pattern: /\bapp\.pluginsMap\b/g, message: 'app.pluginsMap is internal — not part of the plugin API', }, { pattern: /\bhistoryApiHttpRegistry\b/g, message: 'historyApiHttpRegistry is internal (explicitly hidden from plugins) — use app.registerHistoryApiProvider()', }, ]; // ── Route registration anti-pattern (warnings) ────────── // Plugins should use registerWithRouter(router) not direct app.get/post const routeAntiPatterns = [ { pattern: /\bapp\.(get|post|put|delete|patch|use)\s*\(\s*['"`\/]/g, message: 'Direct Express route on app object — use registerWithRouter(router) or signalKApiRoutes(router) instead', }, ]; // ── File storage anti-patterns (warnings) ─────────────── // Plugins should use app.getDataDirPath() for data files, // not write to __dirname (node_modules), process.cwd() (server dir), // or access app.config.configPath directly const fileStorageAntiPatterns = [ { pattern: /\b(writeFileSync|writeFile|mkdirSync|mkdir|appendFileSync|createWriteStream)\b[^)]*\b__dirname\b/g, message: 'Writing files relative to __dirname — this writes into node_modules. Use app.getDataDirPath() instead', }, { pattern: /\b(writeFileSync|writeFile|mkdirSync|mkdir|appendFileSync|createWriteStream)\b[^)]*\bprocess\.cwd\(\)/g, message: 'Writing files relative to process.cwd() — this writes into the server directory. Use app.getDataDirPath() instead', }, { pattern: /\bapp\.config\.configPath\b/g, message: 'Direct access to app.config.configPath — use app.getDataDirPath() for plugin data or app.savePluginOptions() for config', }, ]; // ── Security anti-patterns (warnings) ───────────────────── // Plugin routes via registerWithRouter() are automatically admin-protected. // PUT handlers via registerPutHandler() have built-in permission checks. // Plugins should NOT implement their own auth logic or access security internals. const securityAntiPatterns = [ { pattern: /\bapp\.securityStrategy\b/g, message: 'Direct access to app.securityStrategy — this is a server internal. Plugin routes via registerWithRouter() are already admin-protected; PUT handlers via registerPutHandler() have built-in permission checks', }, { pattern: /\bisDummy\s*\(\)/g, message: 'Checking isDummy() to branch security logic — plugins should not change behavior based on security mode. Use the built-in route protection instead', }, { pattern: /\breq\.skPrincipal\b/g, message: 'Manual req.skPrincipal inspection — plugin routes under /plugins/* are already admin-only. If you need user identity, use req.skPrincipal.identifier but do not implement your own permission checks', }, { pattern: /\baddAdminWriteMiddleware\b/g, message: 'addAdminWriteMiddleware is a server internal — plugin routes via registerWithRouter() are already admin-protected', }, { pattern: /\baddWriteMiddleware\b/g, message: 'addWriteMiddleware is a server internal — use registerPutHandler() for write operations with built-in permission checks', }, ]; const srcFiles = []; function collectFiles(dir) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist') continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { collectFiles(full); continue; } if (/\.(js|ts|mjs|cjs)$/.test(entry.name)) srcFiles.push(full); } } collectFiles('.'); const warnings = []; const errors = []; for (const file of srcFiles) { const content = fs.readFileSync(file, 'utf8'); const lines = content.split('\n'); const relFile = path.relative('.', file); // Strip comments before scanning so commented-out examples (either // "// app.server" or "/* app.server */" forms, on their own line // or inline with real code) do not trigger API misuse warnings. // String literals are not parsed — a comment-like sequence inside // a string is still treated as a comment, an accepted false-pos. const codeLines = new Array(lines.length); let inBlock = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; let out = ''; let pos = 0; while (pos < line.length) { if (inBlock) { const end = line.indexOf('*/', pos); if (end === -1) { pos = line.length; break; } inBlock = false; pos = end + 2; } else if (line.startsWith('//', pos)) { break; // rest of line is a // comment } else if (line.startsWith('/*', pos)) { inBlock = true; pos += 2; } else { out += line[pos]; pos += 1; } } codeLines[i] = out; } function scanPatterns(patterns, target) { for (const pat of patterns) { for (let i = 0; i < lines.length; i++) { if (!codeLines[i]) continue; if (pat.pattern.test(codeLines[i])) { target.push(relFile + ':' + (i + 1) + ' — ' + pat.message); } pat.pattern.lastIndex = 0; } } } scanPatterns(deprecated, warnings); scanPatterns(misuse, errors); scanPatterns(routeAntiPatterns, warnings); scanPatterns(fileStorageAntiPatterns, warnings); scanPatterns(securityAntiPatterns, warnings); } // ── Node built-in module version checks ──────────────── // Some node: built-in modules require specific Node versions. // If a plugin uses them but engines.node allows older versions, it will crash. const pkg = require(path.resolve('package.json')); const enginesNode = pkg.engines && pkg.engines.node; // Extract minimum Node version from engines.node (e.g. ">=18", ">=18.0.0", "^20", "18.x") function parseMinNodeVersion(range) { if (!range) return null; const m = range.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/); if (!m) return null; return [parseInt(m[1], 10), parseInt(m[2] || '0', 10), parseInt(m[3] || '0', 10)]; } function versionLt(a, b) { // a, b are [major, minor, patch] arrays for (let i = 0; i < 3; i++) { if (a[i] < b[i]) return true; if (a[i] > b[i]) return false; } return false; } const nodeBuiltinVersionReqs = [ { pattern: /\brequire\s*\(\s*['"]node:sqlite['"]\s*\)|from\s+['"]node:sqlite['"]/g, module: 'node:sqlite', minVersion: [22, 5, 0] }, { pattern: /\brequire\s*\(\s*['"]node:test['"]\s*\)|from\s+['"]node:test['"]/g, module: 'node:test', minVersion: [18, 0, 0], testOnly: true }, ]; for (const file of srcFiles) { const content = fs.readFileSync(file, 'utf8'); const relFile = path.relative('.', file); for (const check of nodeBuiltinVersionReqs) { check.pattern.lastIndex = 0; if (!check.pattern.test(content)) { check.pattern.lastIndex = 0; continue; } check.pattern.lastIndex = 0; // Skip test-only modules in test files if (check.testOnly && /\b(test|spec|__tests__)\b/i.test(relFile)) continue; // If the file has try/catch or .catch() around the import, it handles // the module being unavailable gracefully (e.g. dynamic import with fallback). // Downgrade to warning instead of error. const hasTryCatch = /try\s*\{[\s\S]*?\brequire\s*\(\s*['"]node:(sqlite|test)['"]\s*\)[\s\S]*?\}\s*catch/g.test(content) || /import\s*\(\s*['"]node:(sqlite|test)['"]\s*\)/.test(content); const minVerStr = check.minVersion.join('.'); if (!enginesNode) { // No engines.node at all — always an error. The plugin MUST declare // its minimum Node version so servers and users know the requirement. errors.push(relFile + ' — uses ' + check.module + ' (requires Node >= ' + minVerStr + ') but package.json has no engines.node field. Add engines.node to declare the minimum version.'); } else { const minDeclared = parseMinNodeVersion(enginesNode); if (minDeclared && versionLt(minDeclared, check.minVersion)) { // engines.node is set but allows older versions. If the plugin handles // the module being unavailable (dynamic import with fallback), downgrade // to warning — the plugin works in degraded mode on older Node. const target = hasTryCatch ? warnings : errors; const suffix = hasTryCatch ? ' (dynamic import with fallback detected — verify graceful degradation)' : ''; target.push(relFile + ' — uses ' + check.module + ' (requires Node >= ' + minVerStr + ') but engines.node "' + enginesNode + '" allows Node ' + minDeclared.join('.') + suffix); } } } } if (warnings.length === 0 && errors.length === 0) { console.log('No deprecated or misused API patterns found'); } warnings.forEach(w => console.log('::warning::' + w)); errors.forEach(e => console.log('::error::' + e)); if (errors.length > 0) { console.log(''); console.log('Found ' + errors.length + ' API misuse(s). These access internal server'); console.log('properties that are not part of the plugin API and may break'); console.log('without notice in future server updates.'); console.log(''); console.log('See: https://signalk.org/signalk-server/develop/plugins/server_api.html'); process.exit(1); } process.exit(0); APISCAN node /tmp/check-api-usage.js - name: Verify npm pack includes all required files id: pack-check shell: bash timeout-minutes: 2 run: | cat << 'PACKCHECK' > /tmp/check-pack.js const { execSync } = require('child_process'); const pkg = require(process.cwd() + '/package.json'); const path = require('path'); // See validate-entry.js for the rationale. Null fallback: if the // plugin declares no main/exports, there is nothing to check. function resolveEntry(p, fallback) { if (p && p.main) return p.main; const e = p && p.exports; if (typeof e === 'string') return e; if (e && typeof e === 'object') { const root = typeof e['.'] !== 'undefined' ? e['.'] : e; if (typeof root === 'string') return root; if (root && typeof root === 'object') { for (const k of ['require', 'node', 'default', 'import']) { if (typeof root[k] === 'string') return root[k]; } } } return fallback; } // Get list of files that npm pack would include // --ignore-scripts: don't re-run prepack (we already built in a prior step) // stdio pipe suppresses stderr cross-platform (2>/dev/null breaks on Windows) let packOutput; try { packOutput = execSync('npm pack --dry-run --json --ignore-scripts', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); } catch (e) { packOutput = e.stdout || ''; } let files; try { const parsed = JSON.parse(packOutput); files = parsed[0].files.map(f => f.path); } catch (e) { // Fallback: parse non-JSON dry-run output let raw; try { raw = execSync('npm pack --dry-run --ignore-scripts', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); } catch (e2) { raw = e2.stdout || ''; } files = raw.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('npm') && !l.startsWith('Tarball')); } const errors = []; const warnings = []; // Check main entry point is included const main = resolveEntry(pkg, null); if (main && !files.includes(main) && !files.includes(main.replace(/^\.\//, ''))) { errors.push('"main" field points to ' + main + ' but this file is NOT in the npm package.'); errors.push('The plugin will be broken when installed from npm. Add it to "files" in package.json or remove it from .npmignore.'); } // Check exports map entries if (pkg.exports && typeof pkg.exports === 'object') { function checkExports(obj, prefix) { for (const [key, val] of Object.entries(obj)) { if (typeof val === 'string') { const clean = val.replace(/^\.\//, ''); if (!files.includes(clean) && !files.includes(val)) { errors.push('"exports" entry ' + prefix + key + ' points to ' + val + ' but it is NOT in the npm package.'); } } else if (typeof val === 'object') { checkExports(val, prefix + key + '.'); } } } checkExports(pkg.exports, ''); } // Check for schema.json if plugin defines schema() if (files.some(f => /schema\.json/i.test(f))) { console.log('schema.json found in package — good'); } // Warn if dist/ or lib/ referenced but not included if (main && (main.startsWith('dist/') || main.startsWith('lib/'))) { const dir = main.split('/')[0]; if (!files.some(f => f.startsWith(dir + '/'))) { errors.push('"main" references ' + dir + '/ directory but no files from it are in the package. Did you forget to build, or need to add "' + dir + '" to the "files" array in package.json?'); } } warnings.forEach(w => console.log('::warning::' + w)); if (errors.length > 0) { errors.forEach(e => console.log('::error::' + e)); process.exit(1); } console.log('All entry points are included in the npm package'); process.exit(0); PACKCHECK node /tmp/check-pack.js - name: Check ES2023 compatibility (Cerbo GX / Node 20) id: es-check if: always() && steps.pack-check.outcome != 'skipped' shell: bash run: | # Cerbo GX (Venus OS) runs Node 20 which supports ES2023 syntax. # Plugins using ES2024+ syntax (RegExp v flag, import attributes) # will crash with SyntaxError on Node 20. # Only check server-side JS (dist/, lib/, main entry) — skip public/ # which is frontend code running in browsers. FILES="" for dir in dist lib; do if [ -d "$dir" ]; then for ext in js cjs mjs; do if find "$dir" -name "*.$ext" -print -quit 2>/dev/null | grep -q .; then FILES="$FILES $dir/**/*.$ext" fi done fi done MAIN=$(node -e " const pkg = require('./package.json'); function resolveEntry(p, fallback) { if (p && p.main) return p.main; const e = p && p.exports; if (typeof e === 'string') return e; if (e && typeof e === 'object') { const root = typeof e['.'] !== 'undefined' ? e['.'] : e; if (typeof root === 'string') return root; if (root && typeof root === 'object') { for (const k of ['require', 'node', 'default', 'import']) { if (typeof root[k] === 'string') return root[k]; } } } return fallback; } const main = resolveEntry(pkg, null); if (main) console.log(main); " 2>/dev/null) if [ -n "$MAIN" ] && [ -f "$MAIN" ] && [ -z "$FILES" ]; then FILES="$MAIN" fi if [ -z "$FILES" ]; then echo "No built JS files found to check" exit 0 fi echo "Checking ES2023 compatibility: $FILES" if ! npx --yes es-check@8 es2023 $FILES 2>&1; then echo "" echo "::warning::Plugin uses ES2024+ syntax not supported by Node 20 (Cerbo GX / Venus OS). Consider targeting ES2023 in your TypeScript/build config or tsconfig.json to maintain Cerbo GX compatibility." fi - name: Simulate App Store install (--ignore-scripts) id: appstore-check shell: bash env: APPSTORE_DIR: ${{ runner.temp }}/appstore-test run: | # SignalK server installs plugins with --ignore-scripts (src/modules.ts:223) # This means native addons (better-sqlite3, serialport, etc.) are NOT compiled. # This test catches plugins that will break when installed via the App Store. echo "Simulating SignalK App Store install (npm install --ignore-scripts)..." PLUGIN_TGZ=$(npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP") mkdir -p "$APPSTORE_DIR" cd "$APPSTORE_DIR" npm init -y > /dev/null 2>&1 npm install --ignore-scripts "$RUNNER_TEMP/$(basename "$PLUGIN_TGZ")" 2>&1 # Scan for native addon dependencies (binding.gyp = needs compilation) # Uses Node.js for cross-platform compatibility (works on Linux, macOS, Windows) cat << 'CHECKSCRIPT' > "$RUNNER_TEMP/check-native.js" const fs = require('fs'); const path = require('path'); const nmDir = path.join(process.env.APPSTORE_DIR, 'node_modules'); const nativeModules = []; function findNativeModules(dir) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir)) { if (entry === '.package-lock.json') continue; const full = path.join(dir, entry); if (entry.startsWith('@')) { findNativeModules(full); continue; } if (fs.existsSync(path.join(full, 'binding.gyp'))) { nativeModules.push(entry); } const nested = path.join(full, 'node_modules'); if (fs.existsSync(nested)) findNativeModules(nested); } } findNativeModules(nmDir); if (nativeModules.length === 0) { console.log('No native addons found — App Store install is safe'); process.exit(0); } // Classify native modules as required vs optional using the lockfile const optionalSet = new Set(); const lockPath = path.join(nmDir, '.package-lock.json'); if (fs.existsSync(lockPath)) { try { const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); for (const [key, meta] of Object.entries(lock.packages || {})) { if (meta.optional) optionalSet.add(path.basename(key)); } } catch (e) { /* ignore parse errors — treat all as required */ } } const requiredNative = nativeModules.filter(m => !optionalSet.has(m)); const optionalNative = nativeModules.filter(m => optionalSet.has(m)); if (optionalNative.length > 0) { console.log('::warning::Optional native addons found: ' + optionalNative.join(', ') + '. App Store uses --ignore-scripts so these will not be compiled. The parent package declares them optional, so the plugin may still work — verify it works without them.'); } if (requiredNative.length > 0) { console.log('::error::Plugin depends on native addons: ' + requiredNative.join(', ') + '. App Store uses --ignore-scripts — native addons will NOT be compiled and the plugin will CRASH at runtime.'); console.log(''); console.log('Use alternatives that do not require native compilation, e.g.:'); console.log(' - node:sqlite (built into Node >= 22) instead of better-sqlite3'); console.log(' - Pure JavaScript packages instead of native addons'); process.exit(1); } process.exit(0); CHECKSCRIPT node "$RUNNER_TEMP/check-native.js" - name: Lint (if available) run: npm run lint --if-present continue-on-error: true - name: Check formatting if: ${{ inputs.format-check-command != '' }} env: FORMAT_CMD: ${{ inputs.format-check-command }} shell: bash run: eval "$FORMAT_CMD" - name: Run tests with coverage if: ${{ inputs.coverage-command != '' }} id: coverage env: COVERAGE_CMD: ${{ inputs.coverage-command }} shell: bash run: | set -eo pipefail eval "$COVERAGE_CMD" | tee "$RUNNER_TEMP/coverage.log" { echo '## Test coverage (Node ${{ matrix.node }} / ${{ runner.os }})' echo echo '```' cat "$RUNNER_TEMP/coverage.log" echo '```' } >> "$GITHUB_STEP_SUMMARY" - name: Run tests if: ${{ inputs.coverage-command == '' }} id: tests env: TEST_CMD: ${{ inputs.test-command }} shell: bash run: | # Detect if the plugin has a real test script HAS_TESTS=$(node -e " const pkg = require('./package.json'); const test = pkg.scripts && pkg.scripts.test; // npm init sets test to 'echo \"Error: no test specified\" && exit 1' if (!test || test.includes('no test specified')) { process.exit(1); } " && echo "yes" || echo "no") if [ "$HAS_TESTS" = "yes" ]; then eval "$TEST_CMD" else echo "::notice::No test script defined — skipping tests" fi - name: Check for untracked files after build/test id: stray-files shell: bash run: | # Plugins should not leave stray files in the repo after install/build/test. UNTRACKED=$(git ls-files --others --exclude-standard -- ':!node_modules' ':!.npm') if [ -n "$UNTRACKED" ]; then echo "::warning::Build/test left untracked files in the repository:" echo "$UNTRACKED" | while read -r f; do echo " $f"; done echo "" echo "These files would pollute the signalk-server git directory when" echo "the plugin is installed. Add them to .gitignore or fix the build" echo "to write output into node_modules or a temp directory." fi - name: Write job summary if: always() shell: bash env: VALIDATE_PKG: ${{ steps.validate-pkg.outcome }} VALIDATE_ENTRY: ${{ steps.validate-entry.outcome }} VALIDATE_SCHEMA: ${{ steps.validate-schema.outcome }} LIFECYCLE_CHECK: ${{ steps.lifecycle-check.outcome }} DEPRECATED_API: ${{ steps.deprecated-api.outcome }} PACK_CHECK: ${{ steps.pack-check.outcome }} APPSTORE_CHECK: ${{ steps.appstore-check.outcome }} # When coverage-command is set, steps.tests is skipped and the # coverage step runs instead — surface whichever actually ran. TESTS: ${{ inputs.coverage-command != '' && steps.coverage.outcome || steps.tests.outcome }} STRAY_FILES: ${{ steps.stray-files.outcome }} ES_CHECK: ${{ steps.es-check.outcome }} OS_NAME: >- ${{ matrix.os == 'ubuntu-latest' && 'Linux' || matrix.os == 'ubuntu-24.04-arm' && 'Linux arm64' || matrix.os == 'macos-latest' && 'macOS' || matrix.os == 'windows-latest' && 'Windows' || matrix.os }} NODE_VER: ${{ matrix.node }} run: | icon() { case "$1" in success) echo "pass" ;; failure) echo "FAIL" ;; skipped) echo "skip" ;; *) echo "—" ;; esac } PLUGIN_NAME=$(node -e "console.log(require('./package.json').name)" 2>/dev/null || echo "unknown") PLUGIN_VER=$(node -e "console.log(require('./package.json').version)" 2>/dev/null || echo "?") { echo "### $OS_NAME / Node $NODE_VER — $PLUGIN_NAME@$PLUGIN_VER" echo "" echo "| Check | Result |" echo "|-------|--------|" echo "| package.json valid | $(icon "$VALIDATE_PKG") |" echo "| Entry point exports function | $(icon "$VALIDATE_ENTRY") |" echo "| plugin.schema() valid | $(icon "$VALIDATE_SCHEMA") |" echo "| start/stop/restart lifecycle | $(icon "$LIFECYCLE_CHECK") |" echo "| API usage (no deprecated/internal) | $(icon "$DEPRECATED_API") |" echo "| npm pack includes all files | $(icon "$PACK_CHECK") |" echo "| App Store compatible (no native addons) | $(icon "$APPSTORE_CHECK") |" echo "| Tests / coverage | $(icon "$TESTS") |" echo "| ES2023 compatible (Cerbo GX / Node 20) | $(icon "$ES_CHECK") |" echo "| No stray files after build | $(icon "$STRAY_FILES") |" } >> "$GITHUB_STEP_SUMMARY" - name: Upload test artifacts if: always() uses: actions/upload-artifact@v7 with: name: test-results-${{ matrix.os }}-node${{ matrix.node }} path: | test-results/ coverage/ junit*.xml if-no-files-found: ignore retention-days: 7 # ════════════════════════════════════════════════════════════ # armv7 — Cerbo GX / Raspberry Pi (32-bit ARM via QEMU) # ════════════════════════════════════════════════════════════ armv7: name: armv7 (Cerbo GX) / Node 20 runs-on: ubuntu-latest timeout-minutes: 30 if: ${{ inputs.enable-armv7 }} # Expose the test-step outcome so ci-status can distinguish a step # failure (advisory) from the job itself completing. outputs: advisory-outcome: ${{ steps.armv7-test.outcome }} steps: - name: Checkout plugin uses: actions/checkout@v6 - name: Set up QEMU for ARM emulation # Advisory: a QEMU setup failure must not block the workflow — the # armv7 test step handles its own failure via continue-on-error, and # if QEMU truly didn't initialize, docker run will fail there instead. continue-on-error: true uses: docker/setup-qemu-action@v4 with: platforms: linux/arm/v7 - name: Cache npm for armv7 uses: actions/cache@v5 with: path: /tmp/.npm-armv7-cache key: armv7-npm-${{ hashFiles('**/package-lock.json', '**/package.json') }} restore-keys: | armv7-npm- - name: Test on armv7 via QEMU id: armv7-test # Advisory: do not fail the workflow when armv7 tests fail. The # outcome is captured via the job output above and surfaced in the # ci-status summary. continue-on-error: true env: BUILD_CMD: ${{ inputs.build-command }} TEST_CMD: ${{ inputs.test-command }} run: | docker run --rm --platform linux/arm/v7 \ -v "${{ github.workspace }}:/plugin" \ -v "/tmp/.npm-armv7-cache:/root/.npm" \ -w /plugin \ -e BUILD_CMD \ -e TEST_CMD \ node:20-bookworm-slim \ bash -c ' set -e echo "── System info ──" echo "arch: $(uname -m) node: $(node -v) npm: $(npm -v)" echo "── Installing build tools (python3, make, g++) ──" apt-get update -qq && apt-get install -y -qq python3 make g++ > /dev/null 2>&1 echo "── Installing dependencies ──" if [ -f package-lock.json ]; then npm ci --ignore-scripts else npm install --ignore-scripts fi npm rebuild echo "── Building ──" eval "$BUILD_CMD" echo "── Checking for native addons ──" node -e " const fs = require(\"fs\"), path = require(\"path\"); const nm = []; function scan(dir) { if (!fs.existsSync(dir)) return; for (const e of fs.readdirSync(dir)) { if (e === \".package-lock.json\") continue; const f = path.join(dir, e); if (e.startsWith(\"@\")) { scan(f); continue; } if (fs.existsSync(path.join(f, \"binding.gyp\"))) nm.push(e); const n = path.join(f, \"node_modules\"); if (fs.existsSync(n)) scan(n); } } scan(\"./node_modules\"); if (nm.length === 0) { console.log(\"No native addons\"); process.exit(0); } console.log(\"Native addons compiled for armv7: \" + nm.join(\", \")); " echo "── Running tests ──" HAS_TESTS=$(node -e " const pkg = require(\"./package.json\"); const test = pkg.scripts && pkg.scripts.test; if (!test || test.includes(\"no test specified\")) process.exit(1); " && echo "yes" || echo "no") if [ "$HAS_TESTS" = "yes" ]; then eval "$TEST_CMD" else echo "::notice::No test script defined — skipping tests" fi ' - name: Write job summary if: always() shell: bash run: | PLUGIN_NAME=$(node -e "console.log(require('./package.json').name)" 2>/dev/null || echo "unknown") PLUGIN_VER=$(node -e "console.log(require('./package.json').version)" 2>/dev/null || echo "?") # Use the test-step outcome, not job.status — the job itself # succeeds (continue-on-error) even when the step fails. RESULT="${{ steps.armv7-test.outcome }}" { echo "### armv7 (Cerbo GX) / Node 20 — ${PLUGIN_NAME}@${PLUGIN_VER}" echo "" echo "**Environment**: Venus OS 3.70 emulation (QEMU armv7, Debian Bookworm, python3, make, g++)" echo "" if [[ "$RESULT" == "success" ]]; then echo "Install, build, and tests completed successfully under armv7 emulation." else echo "The armv7 job **failed**. Your plugin may not work on 32-bit ARM devices (Cerbo GX, Raspberry Pi OS 32-bit)." fi echo "" echo "> This test is **advisory and non-blocking** — armv7 failures do not fail the overall CI." } >> "$GITHUB_STEP_SUMMARY" # ════════════════════════════════════════════════════════════ # Optional: SignalK server integration test # ════════════════════════════════════════════════════════════ signalk-integration: name: Integration / signalk-server ${{ matrix.signalk-server-version }} / Node ${{ matrix.node }} runs-on: ubuntu-latest timeout-minutes: 15 if: ${{ inputs.enable-signalk-integration }} strategy: fail-fast: false matrix: node: ${{ fromJson(inputs.node-versions) }} signalk-server-version: ${{ fromJson(inputs.signalk-server-versions) }} steps: - name: Checkout plugin uses: actions/checkout@v6 - name: Setup Node.js ${{ matrix.node }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} cache: ${{ hashFiles('**/package-lock.json') != '' && 'npm' || '' }} - name: Install SignalK server ${{ matrix.signalk-server-version }} run: | mkdir -p /tmp/sk-test cd /tmp/sk-test npm init -y npm install signalk-server@${{ matrix.signalk-server-version }} - name: Build plugin env: BUILD_CMD: ${{ inputs.build-command }} shell: bash run: | if [ -f package-lock.json ]; then npm ci else npm install fi eval "$BUILD_CMD" - name: Install plugin into SignalK run: | # Pack the plugin and install it into the test server. Use basename # defensively: some npm versions emit a path-prefixed tarball name. PLUGIN_TGZ=$(npm pack --ignore-scripts --pack-destination /tmp) cd /tmp/sk-test npm install "/tmp/$(basename "$PLUGIN_TGZ")" - name: Configure plugin to auto-start run: | cd /tmp/sk-test PLUGIN_PKG_NAME=$(node -e "console.log(require('${{ github.workspace }}/package.json').name)") # Derive plugin ID the same way signalk-server does (src/pluginid.ts) PLUGIN_ID=$(node -e " const name = process.argv[1]; console.log(name.replace(/@/g, '_').replace(/\//g, '_')); " "$PLUGIN_PKG_NAME") echo "Plugin package: $PLUGIN_PKG_NAME" echo "Plugin ID: $PLUGIN_ID" mkdir -p plugin-config-data echo '{"enabled": true, "configuration": {}}' > "plugin-config-data/${PLUGIN_ID}.json" - name: Start SignalK server and verify plugin env: SIGNALK_URL: 'http://localhost:3000' run: | cd /tmp/sk-test export SIGNALK_NODE_CONFIG_DIR=/tmp/sk-test # Start server with both NMEA 0183 and N2K sample data so plugins # have a rich data environment (navigation, wind, depth, temperature, etc.) # --override-timestamps keeps N2K data current instead of stale npx signalk-server --sample-nmea0183-data --sample-n2k-data --override-timestamps -p 3000 & SK_PID=$! # Ensure the background server is killed on any exit path — step # failure, early return from a check, or normal completion. Without # this a failing `npm run test:integration` would leak the process. trap 'kill $SK_PID 2>/dev/null || true' EXIT # Wait for server to be ready (max 90 seconds) echo "Waiting for SignalK server..." READY=false for i in $(seq 1 45); do if curl -sf http://localhost:3000/signalk/v1/api/ > /dev/null 2>&1; then READY=true break fi sleep 2 done if [ "$READY" != "true" ]; then echo "::error::SignalK server failed to start within 90 seconds" exit 1 fi echo "SignalK server is ready (PID $SK_PID)" # Verify plugin is loaded PLUGIN_PKG_NAME=$(node -e "console.log(require('${{ github.workspace }}/package.json').name)") PLUGINS_JSON=$(curl -sf http://localhost:3000/skServer/plugins) echo "Loaded plugins:" echo "$PLUGINS_JSON" | jq -r '.[] | " - \(.packageName // .id) (\(.state // "unknown"))"' # Assert our plugin is in the list if ! echo "$PLUGINS_JSON" | jq -e ".[] | select(.packageName == \"$PLUGIN_PKG_NAME\")" > /dev/null 2>&1; then echo "::error::Plugin '$PLUGIN_PKG_NAME' not found in loaded plugins" echo "$PLUGINS_JSON" | jq . exit 1 fi echo "Plugin '$PLUGIN_PKG_NAME' is loaded" # Verify provider API registrations # If the plugin registers as a provider (history, resources, etc.), # check that the corresponding server endpoints actually respond. # This catches the case where a plugin calls registerHistoryApiProvider() # but the endpoint still returns 501 (broken registration), or where a # webapp/plugin depends on an API that no installed provider serves. echo "" echo "Checking provider API registrations..." PROVIDER_ERRORS=0 # History API v2 — check if plugin registered as a history provider HISTORY_PROVIDERS=$(curl -sf http://localhost:3000/signalk/v2/api/history/_providers || echo "{}") if echo "$HISTORY_PROVIDERS" | jq -e 'keys | length > 0' > /dev/null 2>&1; then echo "History API v2: provider(s) registered" echo "$HISTORY_PROVIDERS" | jq -r 'to_entries[] | " - \(.key)\(if .value.isDefault then " (default)" else "" end)"' # Verify the values endpoint responds (not 501) FROM=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-1d +%Y-%m-%dT%H:%M:%SZ) TO=$(date -u +%Y-%m-%dT%H:%M:%SZ) # No -f: we need to capture 4xx/5xx codes (notably 501) instead of # exiting early with an empty HISTORY_STATUS. HISTORY_STATUS=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000/signalk/v2/api/history/values?paths=navigation.position&from=${FROM}&to=${TO}&resolution=60" || echo "000") if [ "$HISTORY_STATUS" = "501" ]; then echo "::error::History API v2: provider registered but /history/values returns 501 (no provider configured)" PROVIDER_ERRORS=$((PROVIDER_ERRORS + 1)) elif [ "$HISTORY_STATUS" = "200" ]; then echo "History API v2: /history/values responds OK" else echo "::warning::History API v2: /history/values returned HTTP $HISTORY_STATUS" fi else echo "History API v2: no providers registered (OK — plugin does not provide history)" fi if [ "$PROVIDER_ERRORS" -gt 0 ]; then echo "" echo "::error::Provider API verification found $PROVIDER_ERRORS error(s)" exit 1 fi # Run integration tests from the plugin repo if they exist cd "${{ github.workspace }}" HAS_INTEGRATION=$(node -e " const pkg = require('./package.json'); if (pkg.scripts && pkg.scripts['test:integration']) process.exit(0); process.exit(1); " && echo "yes" || echo "no") if [ "$HAS_INTEGRATION" = "yes" ]; then echo "Running integration tests..." SIGNALK_URL=http://localhost:3000 npm run test:integration else echo "::notice::No test:integration script found — skipping" fi # Server cleanup is handled by the EXIT trap set above. # ════════════════════════════════════════════════════════════ # Summary — collect all results # ════════════════════════════════════════════════════════════ ci-status: name: CI Status runs-on: ubuntu-latest timeout-minutes: 5 if: always() needs: [desktop, armv7, signalk-integration] steps: - name: Checkout plugin uses: actions/checkout@v6 with: sparse-checkout: package.json - name: Write CI summary report env: DESKTOP_RESULT: ${{ needs.desktop.result }} # Prefer the armv7 test-step outcome when the job ran — it records # whether the advisory tests actually passed. Falls back to # needs.armv7.result when the job was skipped/cancelled and no # step outcome exists. ARMV7_RESULT: ${{ needs.armv7.outputs.advisory-outcome || needs.armv7.result }} INTEGRATION_RESULT: ${{ needs.signalk-integration.result }} shell: bash run: | icon() { case "$1" in success) echo "pass" ;; failure) echo "FAIL" ;; skipped) echo "skip" ;; cancelled) echo "cancel" ;; *) echo "—" ;; esac } PLUGIN_NAME=$(node -e "console.log(require('./package.json').name)" 2>/dev/null || echo "unknown") PLUGIN_VER=$(node -e "console.log(require('./package.json').version)" 2>/dev/null || echo "?") OVERALL="pass" if [[ "$DESKTOP_RESULT" == "failure" ]] || [[ "$INTEGRATION_RESULT" == "failure" ]]; then OVERALL="FAIL" fi { echo "## SignalK Plugin CI Report" echo "" echo "**Plugin**: \`$PLUGIN_NAME@$PLUGIN_VER\`" echo "**Result**: $OVERALL" echo "" echo "### Platform results" echo "" echo "| Job | Result | Notes |" echo "|-----|--------|-------|" echo "| Desktop (Linux, Linux arm64, macOS, Windows) | $(icon "$DESKTOP_RESULT") | Node 22, 24 |" echo "| armv7 — Cerbo GX (QEMU) | $(icon "$ARMV7_RESULT") | Node 20 (Venus OS 3.70) — advisory, non-blocking |" if [[ "$INTEGRATION_RESULT" != "skipped" ]]; then echo "| SignalK integration | $(icon "$INTEGRATION_RESULT") | Server install + plugin load |" fi echo "" echo "### What was checked" echo "" echo "Every desktop job validates:" echo "- **package.json** — \`signalk-node-server-plugin\` keyword, \`main\`/\`exports\` field, \`engines.node\`" echo "- **Entry point** — Plugin exports a constructor function" echo "- **plugin.schema()** — Returns valid JSON Schema without crashing" echo "- **Lifecycle** — start()/stop()/restart cycle works without errors" echo "- **API usage** — No deprecated (\`setProviderStatus\`), internal (\`app.server\`), file storage, or security anti-patterns" echo "- **Node built-in modules** — \`node:sqlite\` requires \`engines.node >= 22.5.0\` declared in package.json" echo "- **npm pack** — All files referenced by \`main\`/\`exports\` are included in the package" echo "- **App Store install** — No native addons that break with \`--ignore-scripts\`" echo "- **Hardcoded paths** — No \`/home/user/...\` literals in source files" echo "- **ES2023 compatibility** — Built JS checked for Cerbo GX (Node 20) syntax compatibility" echo "- **baconjs** — Plugins must not bundle their own baconjs; the server provides >= 3.x" echo "- **React version** — Plugins with webapp keywords must use React >= 19 for Module Federation compatibility" echo "- **Stray files** — Build/test does not leave untracked files in the repo" echo "" echo "See the individual job summaries above for per-platform details." } >> "$GITHUB_STEP_SUMMARY" if [[ "$INTEGRATION_RESULT" == "skipped" ]]; then { echo "" echo "> **Tip**: You can also test your plugin against a running Signal K server." echo "> Add \`enable-signalk-integration: true\` to your workflow. [Learn more](https://signalk.org/signalk-server/develop/plugins/ci.html#integration-tests)" } >> "$GITHUB_STEP_SUMMARY" fi if [[ "$ARMV7_RESULT" == "failure" ]]; then { echo "" echo "> **Note**: armv7 (Cerbo GX) failed but this is non-blocking." echo "> Your plugin may not work on 32-bit ARM devices (Cerbo GX, Raspberry Pi OS 32-bit)." } >> "$GITHUB_STEP_SUMMARY" fi echo "" echo "Desktop: $DESKTOP_RESULT" echo "armv7 (Cerbo GX): $ARMV7_RESULT" echo "SignalK integration: $INTEGRATION_RESULT" # Desktop is mandatory if [[ "$DESKTOP_RESULT" == "failure" ]]; then echo "::error::Desktop tests failed" exit 1 fi # armv7 is advisory (non-blocking) case "$ARMV7_RESULT" in failure) echo "::warning::armv7 (Cerbo GX) tests failed — plugin may not work on 32-bit ARM devices" ;; skipped) echo "armv7 tests were skipped" ;; esac # Integration is mandatory if enabled if [[ "$INTEGRATION_RESULT" == "failure" ]]; then echo "::error::SignalK integration tests failed" exit 1 fi echo "All checks passed!" ================================================ FILE: .github/workflows/release.yml ================================================ name: Release - build & publish modules and server, build & publish docker containers on: push: tags: - 'v*' workflow_dispatch: permissions: id-token: write # Required for OIDC contents: read jobs: build_and_publish: if: github.repository == 'SignalK/signalk-server' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: '24.x' registry-url: 'https://registry.npmjs.org' - name: Install and build all run: | npm cache clean -f npm install npm@latest -g npm install --package-lock-only npm ci && npm cache clean --force npm run build:all - name: Publish server-admin-ui-dependencies run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/server-admin-ui-dependencies/package.json) if ! npm view @signalk/server-admin-ui-dependencies@$LOCAL_VERSION version &>/dev/null; then cd packages/server-admin-ui-dependencies npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish server-admin-ui run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/server-admin-ui/package.json) if ! npm view @signalk/server-admin-ui@$LOCAL_VERSION version &>/dev/null; then cd packages/server-admin-ui npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish server-api run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/server-api/package.json) if ! npm view @signalk/server-api@$LOCAL_VERSION version &>/dev/null; then cd packages/server-api npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish streams run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/streams/package.json) if ! npm view @signalk/streams@$LOCAL_VERSION version &>/dev/null; then cd packages/streams npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish resources-provider run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/resources-provider-plugin/package.json) if ! npm view @signalk/resources-provider@$LOCAL_VERSION version &>/dev/null; then cd packages/resources-provider-plugin npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish typedoc-signalk-theme run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/typedoc-theme/package.json) if ! npm view @signalk/typedoc-signalk-theme@$LOCAL_VERSION version &>/dev/null; then cd packages/typedoc-theme npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish assemblyscript-plugin-sdk run: | LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/assemblyscript-plugin-sdk/package.json) if ! npm view @signalk/assemblyscript-plugin-sdk@$LOCAL_VERSION version &>/dev/null; then cd packages/assemblyscript-plugin-sdk npm publish --access public fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Set tag variable id: vars run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - name: Publish signalk-server run: | if [[ "${{ steps.vars.outputs.tag }}" == *beta* ]]; then npm publish --tag beta else npm publish fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} release: permissions: contents: write if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest needs: build_and_publish steps: - name: Build Changelog id: github_release uses: mikepenz/release-changelog-builder-action@v6 with: ignorePreReleases: 'true' env: GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} - name: Create Release (archived, need to be updated to use the new action) uses: actions/create-release@v1 with: tag_name: ${{ github.ref }} release_name: ${{ github.ref }} prerelease: ${{ contains(github.ref, 'beta') }} body: ${{steps.github_release.outputs.changelog}} env: GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} docker_images: needs: build_and_publish strategy: matrix: os: [24.04, alpine] node: [24.x] vm: [ubuntu-latest, ubuntu-24.04-arm] include: - vm: ubuntu-latest arch: amd platform: linux/amd64 - vm: ubuntu-24.04-arm arch: arm node: 24.x platform: linux/arm64 - os: 24.04 node: 24.x node_safe: 24.x - os: alpine node: 24.x node_safe: 24 runs-on: ${{ matrix.vm }} steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to ghcr.io uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT }} - name: Set TAG for build-args id: vars run: echo "tag=${GITHUB_REF#refs/*/v}" >> $GITHUB_OUTPUT - name: Build and push uses: docker/build-push-action@v7 with: context: . file: ./docker/Dockerfile_rel platforms: ${{ matrix.platform }} push: true tags: ghcr.io/signalk/signalk-server:${{ matrix.arch }}-${{ matrix.os }}-${{ matrix.node_safe }}-${{ github.run_id }} build-args: | TAG=${{ steps.vars.outputs.tag }} BASE_IMAGE=${{ matrix.os }}-${{ matrix.node_safe }} create-and-push-manifest: needs: docker_images runs-on: ubuntu-latest strategy: matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x tag: latest node_safe: 24.x - os: alpine node: 24.x tag: latest suffix: -alpine node_safe: 24 steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Docker meta id: docker_meta uses: docker/metadata-action@v6 with: images: | ghcr.io/signalk/signalk-server tags: | type=semver,pattern={{raw}} type=semver,pattern=v{{major}},enable=${{ !contains(github.ref, 'beta') }} type=semver,pattern=v{{major}}.{{minor}},enable=${{ !contains(github.ref, 'beta') }} type=raw,value=${{ matrix.tag }},enable=${{ !contains(github.ref, 'beta') }} flavor: | suffix=${{ matrix.suffix }} latest=false - name: Login to ghcr.io uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GHCR_PAT }} - name: Create and push multi-arch manifest to GHCR uses: int128/docker-manifest-create-action@v2 with: tags: | ${{ steps.docker_meta.outputs.tags }} sources: | ghcr.io/signalk/signalk-server:amd-${{ matrix.os }}-${{ matrix.node_safe }}-${{ github.run_id }} ghcr.io/signalk/signalk-server:arm-${{ matrix.os }}-${{ matrix.node_safe }}-${{ github.run_id }} - name: Save tags to file run: | mkdir -p /tmp/tags echo "${{ steps.docker_meta.outputs.tags }}" > /tmp/tags/${{ matrix.node_safe }}.txt - name: Upload tag artifact uses: actions/upload-artifact@v7 with: name: release-tag-${{ matrix.node_safe }} path: /tmp/tags/${{ matrix.node_safe }}.txt retention-days: 1 copy-to-dockerhub: needs: create-and-push-manifest runs-on: ubuntu-latest strategy: matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x node_safe: 24.x - os: alpine node: 24.x node_safe: 24 steps: - name: Download tag artifact uses: actions/download-artifact@v8 with: name: release-tag-${{ matrix.node_safe }} path: /tmp/tags - name: Install skopeo run: | sudo apt-get update sudo apt-get install -y skopeo - name: Copy images from GHCR to Docker Hub shell: bash env: GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GHCR_PAT }} DOCKER_HUB_USERNAME: signalkci DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | set -euo pipefail TAGS_FILE="/tmp/tags/${{ matrix.node_safe }}.txt" while IFS= read -r FULL_TAG || [ -n "${FULL_TAG:-}" ]; do [ -z "${FULL_TAG:-}" ] && continue TAG="${FULL_TAG##*:}" echo "Copying: ${FULL_TAG} -> signalk/signalk-server:${TAG}" skopeo copy --all \ --src-creds "${GHCR_USERNAME}:${GHCR_TOKEN}" \ --dest-creds "${DOCKER_HUB_USERNAME}:${DOCKER_HUB_ACCESS_TOKEN}" \ "docker://${FULL_TAG}" \ "docker://docker.io/signalk/signalk-server:${TAG}" done < "$TAGS_FILE" housekeeping: needs: [copy-to-dockerhub] runs-on: ubuntu-latest permissions: packages: write steps: - name: Wait for GHCR indexing run: sleep 60 - name: Remove Temporary & Untagged Docker Images from GHCR continue-on-error: true uses: dataaxiom/ghcr-cleanup-action@v1 with: packages: signalk-server delete-untagged: true delete-tags: | *-${{ github.run_id }} token: ${{ secrets.GHCR_PAT }} deploy_fly: runs-on: ubuntu-latest needs: copy-to-dockerhub steps: - name: Checkout uses: actions/checkout@v6 - name: Setup flyctl uses: superfly/flyctl-actions/setup-flyctl@master - name: Set TAG for build-arg id: vars run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - name: Deploy demo.signalk.org at fly.io working-directory: ./fly_io/demo_signalk_org run: flyctl deploy --remote-only --build-arg SK_VERSION=${{ steps.vars.outputs.tag }} env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} ================================================ FILE: .github/workflows/require_pr_label.yml ================================================ name: Pull Request Labels on: pull_request: types: [opened, labeled, unlabeled, synchronize] jobs: label: runs-on: ubuntu-latest steps: - uses: mheap/github-action-required-labels@v5 with: mode: exactly count: 1 labels: 'fix, feature, doc, chore, test, ignore, other, dependencies, refactor' ================================================ FILE: .github/workflows/security-scan.yml ================================================ name: Security Scan for Docker Images on: schedule: - cron: '0 0 * * 1' workflow_dispatch: permissions: contents: read security-events: write jobs: security-scan: if: github.repository == 'SignalK/signalk-server' name: Security Scan runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: os: [24.04, alpine] node: [24.x] include: - os: 24.04 node: 24.x image-tag: 'latest' - os: alpine node: 24.x image-tag: 'latest-alpine' steps: - name: Checkout code uses: actions/checkout@v6 - name: Run Trivy vulnerability scanner for Docker Image uses: aquasecurity/trivy-action@0.35.0 with: image-ref: 'ghcr.io/signalk/signalk-server:${{ matrix.image-tag }}' format: 'sarif' output: 'trivy-results-docker-node${{ matrix.node }}-${{ matrix.image-tag }}.sarif' - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@v4 if: always() with: sarif_file: 'trivy-results-docker-node${{ matrix.node }}-${{ matrix.image-tag }}.sarif' category: 'trivy-docker-node${{ matrix.node }}-${{ matrix.image-tag }}' ================================================ FILE: .github/workflows/test.yml ================================================ name: CI test concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: pull_request: push: branches: [master] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [22.x, 24.x] steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - uses: SocketDev/action@v1 with: mode: firewall-free - run: sfw npm install - run: npm run build:all - name: resources-provider-plugin working-directory: ./packages/resources-provider-plugin run: | npm run test - run: npm test env: CI: true ================================================ FILE: .gitignore ================================================ node_modules !test/plugin-test-config/node_modules/ *.tsbuildinfo lib/ dist/ .DS_Store .vscode/ *.db logs/* bower_components settings/ssl-key.pem settings/ssl-cert.pem /*.iml *.tgz work/ data/ plugin-config-data/ unitpreferences/custom-units-definitions.json unitpreferences/custom/ unitpreferences/presets/custom/ test/plugin-test-config/.npmrc test/plugin-test-config/plugin-config-data/ test/plugin-test-config/ssl-cert.pem test/plugin-test-config/ssl-key.pem test/plugin-test-config/baseDeltas.json test/plugin-test-config/unitpreferences/ test/server-test-config/applicationData/ test/server-test-config/unitpreferences/ test/server-test-config/ssl-cert.pem test/server-test-config/ssl-key.pem test/server-test-config/plugin-config-data/ docs/built # WASM build artifacts *.wasm # .net builds jco-output/ obj/ bin/ Debug/ Release/ *.ps1 nul # wasmtime build artifacts *.wasm # debug artefacts *.sln test/server-test-config/debug ================================================ FILE: .mocharc.js ================================================ module.exports = { require: ['ts-node/register'], extensions: ['ts', 'tsx', 'js'], timeout: 20000, exit: true } ================================================ FILE: .npmignore ================================================ # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules jspm_packages # Optional npm cache directory .npm # Optional REPL history .node_repl_history src public_src webpack.config.js scss compilation-stats.json .babelrc public/*.hot-update.js public/*.hot-update.json **/stats.json **/*.js.map plugin-config-data/ samples/* !samples/plaka.log !samples/aava-n2k.data work/* npm-debug.log .vscode letsencrypt docker test dist/**/*.test.js* packages # Build artifacts from build-docker.yml workflow *.tgz publishing.md bin/linkpackages fly_io .github /docs/* !/docs/dist # .net builds jco-output/ obj/ bin/ Debug/ Release/ *.ps1 nul # wasmtime build artifacts *.wasm # debug artefacts *.sln ================================================ FILE: .npmrc ================================================ package-lock=false legacy-peer-deps=true ================================================ FILE: .nvmrc ================================================ 24 ================================================ FILE: .prettierignore ================================================ *.guard.ts public **/*.mbtiles **/*.pmtiles **/.__mf__temp # AssemblyScript build outputs packages/assemblyscript-plugin-sdk/build ================================================ FILE: .prettierrc.json ================================================ { "semi": false, "singleQuote": true, "trailingComma": "none", "overrides": [ { "files": ["**/assembly/**/*.ts"], "options": { "plugins": ["assemblyscript-prettier"] } } ] } ================================================ FILE: .python-version ================================================ 3.11 ================================================ FILE: AGENTS.md ================================================ # Signal K Server Signal K Server is the reference implementation of a [Signal K](https://signalk.org/) server. Signal K is a modern, open data format and API for marine data. The server aggregates data from various sources (NMEA 0183, NMEA 2000, I2C sensors, etc.), provides a real-time WebSocket API and REST API, and supports a plugin architecture for extensibility. Key components: - **Core server**: Express-based HTTP/WebSocket server (TypeScript) - **Plugin system**: NPM-based plugins with configuration schemas - **Admin UI**: React-based web interface (packages/server-admin-ui) - **Provider patterns**: ResourceProvider, WeatherProvider, AutopilotProvider, HistoryProvider ## Code Quality Principles ### Scope and Complexity Follow YAGNI, SOLID, DRY, and KISS principles. Avoid over-engineering. Only make changes that are directly requested or clearly necessary. Keep solutions simple and focused. Do not add features, refactor code, or make "improvements" beyond what was asked. A bug fix does not need surrounding code cleaned up. A simple feature does not need extra configurability. Do not add error handling, fallbacks, or validation for scenarios that cannot happen. Trust internal code and framework guarantees. Only validate at system boundaries (user input, external APIs). ### General Standards - Write self-documenting code; comments explain "why", not "what" - no echo comments restating what the code already says - Keep functions small and focused on a single responsibility - Prefer composition over inheritance - Handle errors explicitly at system boundaries - No magic numbers; use named constants - Documentation describes current state, not development history - avoid changelog-style language that will become stale ### Type Safety - **All new code must be written in TypeScript**, not JavaScript - When converting JavaScript to TypeScript use pre-existing types when possible instead of creating new local types - Use strict type checking; avoid `any` or equivalent escape hatches - Validate external inputs at system boundaries - Prefer immutable data structures where practical ### Testing - All new code requires tests - Test behavior, not implementation details - Unit tests for business logic; integration tests for boundaries - Aim for meaningful coverage, not arbitrary percentages ## Performance Signal K Server runs on Raspberry Pi 3-5 hardware, often on battery power. CPU cycles cost watts. Treat the delta ingestion and fanout path as allocation-sensitive. ### Hot paths Assume 100+ deltas/sec, 20+ WebSocket clients: - `src/streambundle.ts` `pushDelta` / `push` — per value - `src/subscriptionmanager.ts` subscriber callbacks — per delta per client - `src/interfaces/ws.ts` `onChange`, `data` handler — per client, per message - `src/BackpressureManager.ts` `send` — per delta per client - `src/deltacache.ts` `onValue` — per delta - `src/interfaces/rest.js` tree traversal — per HTTP request ### Rules - **Guard `debug()` arguments.** `debug('x=' + JSON.stringify(obj))` evaluates eagerly even when disabled. Wrap with `debug.enabled &&` (see `src/interfaces/tcp.ts`). - **Build objects in their final shape.** On hot paths, write all properties in a single object literal with consistent key order (V8 hidden class / shape optimization). Do not build up objects incrementally via spread or `Object.assign`. - **Minimize allocations on the per-delta path.** Hoist constants, `Set`s, and closures to module scope. Do not `reduce` into a new array when nothing was removed. Prefer `for...of` over `.forEach`. - **Use `structuredClone`**, not `JSON.parse(JSON.stringify(...))`, for deep cloning. - **Prefer `Set` over `Array.includes`** for repeated membership checks. - **Avoid lodash on hot paths.** `_.get`/`_.set` re-parse path strings; use `obj?.a?.b`. `_.isUndefined(x)` is just `x === undefined`. ## Git Commit Conventions Use conventional format: `(): ` where type = feat|fix|docs|style|refactor|test|chore|perf. Subject: 50 chars max, imperative mood ("add" not "added"), no period. For small changes: one-line commit only. For complex changes: add body explaining what/why (72-char lines) and reference issues. Keep commits small and atomic - one logical change per commit. Split unrelated changes into separate commits. The commit history tells a story; each commit should be a meaningful, self-contained step. **MANDATORY:** Always rebase and clean up commit history before creating a PR or pushing changes. Amend fixes and corrections to the relevant existing commit instead of creating chains of "fix typo" or "oops" commits. The final history should contain only intentional, complete commits - no work-in-progress artifacts. ## Pull Request Guidelines Before opening a PR: - Branch from latest `master` - Run `npm run format` and `npm test` - all checks must pass - Rebase and clean up commit history (squash intermediate commits) - Self-review your changes - **NEVER change version numbers** - maintainers will update versions when publishing releases PR titles are used to generate release notes. Make them **descriptive, informative, and easy to understand**. Ask yourself: "If someone only read the title, would they understand what this PR does?" PR descriptions must be **succinct and straight to the point**. Explain the motivation (why) and summarize the solution approach (how), but not the mechanics (what) - the diff shows what changed. Do not pad descriptions with unnecessary detail, verbose explanations, or self-congratulatory comments. If there are breaking changes, mention them explicitly. If a PR description includes a test plan with checkboxes, **all items must be checked** before the PR is ready for review - remove or complete any unchecked items. When referencing issues, use `closes`, `fixes`, or `resolves` followed by the issue number (e.g., "closes #18", "fixes #21 and resolves #23"). **MANDATORY:** One logical change per PR. Refactoring and behavior changes belong in separate PRs. If changes would result in multiple changelog entries, they should be separate PRs. Even if you have made multiple changes together locally, split them into separate PRs. **AI tools must proactively enforce PR scope.** If a user requests changes unrelated to the current PR topic, do not silently include them. Instead, suggest creating a separate PR for the unrelated work. Similarly, when rebasing or cleaning up commit history, if you detect commits that address different topics, suggest splitting them into separate PRs before proceeding. When updating a branch with upstream changes, **always use rebase, never merge commits**: ```shell git fetch origin git rebase origin/master ``` ================================================ FILE: CHANGELOG.md ================================================ ## Please see [Releases](https://github.com/SignalK/signalk-server-node/releases) for the release notes. ================================================ FILE: CLAUDE.md ================================================ @AGENTS.md ================================================ FILE: CONTRIBUTING.md ================================================ --- title: Contributing --- # Contributing Signal K server is an Open Source project and contributions are welcome. Contributions are made by creating Pull Requests in the [GitHub repository](https://github.com/SignalK/signalk-server). _**Working on your first Pull Request?**_ You can learn how from this _free_ series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) --- ### Additional Guidelines The [AGENTS.md](AGENTS.md) file contains detailed coding guidelines primarily intended for AI coding assistants. The content is equally relevant for human contributors - it covers code quality principles, commit conventions, and PR guidelines that help maintain consistency across the project. Don't worry if some of the instructions seem overly specific or prescriptive - they're written to give AI tools explicit guardrails. As a human, use your judgment; the spirit of the guidelines matters more than following every detail to the letter. --- ### Running the development server 1. Clone the repository: ```shell git clone https://github.com/SignalK/signalk-server cd signalk-server ``` 1. Install dependencies: ```shell npm install ``` 1. Build the server and related packages: ```shell npm run build:all ``` 1. Start the server: ```shell npm start ``` The server should now be available at [http://localhost:3000](http://localhost:3000). As you work on your changes, you may need to re-build changes. To continuously watch for changes, open a new terminal and run `npm run watch` in either the project root, or from the relevant directory in `packages/*`. You may also need to restart the server to see some changes reflected. ### Using sample data Start the server with sample data by running: - NMEA0183 sample data: `bin/nmea-from-file` - NMEA2000 sample data: `bin/n2k-from-file` This will start the server with a sample configuration file and the server will start playing back data from a sample file under `samples/`. The data is available immediately via the REST interface at https://localhost:3000/signalk/v1/api/ and via WebSocket, for example with ``` npm install -g wscat2 wscat 'ws://localhost:3000/signalk/v1/stream?subscribe=all' ``` ### Submitting a Pull Request (PR) Before you submit your Pull Request (PR) consider the following guidelines: 1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository. 1. Make your changes in a new git branch: - `git checkout -b my-fix-branch master` - Do not change the server or package version numbers. They will be changed by the maintainers after the PR is merged, when a new version is published - Create separate PRs for separate things - don't cram unrelated things to one PR, even if you have done them together. If you put multiple changes in one PR and one gets stalled or rejected we could still possibly merge the other one. If changes in one depend on the other one state that in PR description. You can think in terms of release notes: if the changes would be two entries in the changelog they should be separate PRs. 1. Commit your changes using a descriptive commit message that follows the [conventions outlined here](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits). Whilst we are not 100% strict about this, it really helps when reviewing the PR and in making the commit history readable. The TL;DR of it is below. - The subject line should be in the format `: `, where `` should be one of: - feat (feature) - fix (bug fix) - docs (documentation) - style (formatting, missing semi colons, ...) - refactor - test (when adding missing tests) - chore (maintain) - `` should use imperative, present tense: "change" not "changed" or "changes" - Examples of good Subject Lines: - `doc: clarify meta.units behaviour` - `chore: update keyswithmetadata.json` - `style: prettier` - `fix: allow nextPoint to be an intermediate leaf` - `feature: push design object fields under value/values` - Message body should also be **in imperative, present tense** and **include motivation for the change** and **differences to previous behaviour**. - Footer should reference any issues. If the PR should close issue(s) (assuming it is committed), **use closes,fixes or resolves** and the issue number. eg. "closes #18", "fixes #21 and resolves #23". - Subject, Body and Footer are separated by a blank line. 1. Format and lint your code - run `npm run format` to format and [lint]() your code. 1. Push your branch to GitHub: - `git push origin my-fix-branch` 1. In GitHub, create a pull request. - Use the same guidelines as commit messages to write the PR title and description. - The server's release notes are automatically generated from PR titles, so think about how you can make them **descriptive, informative and easy to understand**. Ask yourself: "If I only knew the title would I understand what the PR does?". - The description should tell how the change affects the server's behavior and motivation for doing the change. - If you change the Admin UI include screenshots in the description to help others get a quick idea what changes and how it will look. Before & after pictures are great for this. - If you are using AI **PLEASE TAKE THE TIME to make the PR description succinct and straight to the point**. AIs are really good at creating text, filling in lots of details and adding smug comments how great the PR is. HELP the maintainers so that we don't need to wade through AI fluff. We will ask for more details if too little are included. - Don't include too much detail, like the exact changed lines or a version you tested the change with unless there is specific reason to do so. If the change is not directly related to a version adding a version is misleading. Git shows what's changed and extra content in PR description is just double work for maintainers to read, unless there is something that rquires attention. 1. Wait for labeling and review - PRs are automatically reviewed by [CodeRabbit](https://coderabbit.ai/). Address any comments it raises. Once you are done addressing CodeRabbit's feedback, add a comment **"Ready for human review"** to signal that the PR is ready for maintainer review. - The maintainers will apply a label to the PR. The label is used to group PRs, mainly to distinguish fixes and new features. - If we require changes to your PR we expect you to: - Implement the agreed changes. - Rebase your branch and force push to your GitHub repository (this will update your Pull Request): ```shell git rebase master -i git push -f ``` ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS ================================================ FILE: Procfile ================================================ web: node bin/signalk-server -s ./settings/n2k-from-file-settings.json ================================================ FILE: README.md ================================================ # Signal K Server ![Signal K logo](https://user-images.githubusercontent.com/5200296/226164888-d33b2349-e608-4bed-965f-ebe4339b4376.png) [![npm version](https://badge.fury.io/js/signalk-server.svg)](https://badge.fury.io/js/signalk-server) [![npm license](https://img.shields.io/npm/l/signalk-server.svg)](https://www.npmjs.com/package/signalk-server) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) [![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/signalk)](https://opencollective.com/signalk) ## Contents - [Signal K Server](#signal-k-server) - [Contents](#contents) - [Introduction](#introduction) - [Boaters and Boat Owners](#boaters-and-boat-owners) - [Marine Vendors](#marine-vendors) - [Software Developers \& Boat Electronics Hobbyists](#software-developers--boat-electronics-hobbyists) - [Signal K Platform](#signal-k-platform) - [Documentation, Community \& Support](#documentation-community--support) - [How to get Signal K Server?](#how-to-get-signal-k-server) - [Configuration and use](#configuration-and-use) - [Opening the Signal K Server Admin UI](#opening-the-signal-k-server-admin-ui) - [Creating an admin account](#creating-an-admin-account) - [Setting up data connections](#setting-up-data-connections) - [Installing Plugins and Webapps](#installing-plugins-and-webapps) - [Restart after Configuration Changes and Plugin/Webapp Installation](#restart-after-configuration-changes-and-pluginwebapp-installation) - [Configuring Plugins](#configuring-plugins) - [Vessel Base Data and Server Settings](#vessel-base-data-and-server-settings) - [Server Log](#server-log) - [Supported PGNs, sentences and more](#supported-pgns-sentences-and-more) - [Development](#development) - [Sponsoring Signal K](#sponsoring-signal-k) - [License](#license) ## Introduction Signal K Server is a server application that runs on a central hub in a boat. If you use or develop marine electronics, Signal K Server has something to offer for you. ### Boaters and Boat Owners For boaters, Signal K Server runs in the background and makes functionality and data available to other apps and devices. One of its most used features is to be a wireless AIS and navigation server for popular apps like Navionics, iSailor, iNavX, Aqua Map and WilhelmSK on your phones and tablets. Signal K Server can also take care of the anchor watch, be a weather station or an automatic logbook for you. A different example, it can turn your boat into a MarineTraffic station which may give free access to [the MarineTraffic professional plans](https://help.marinetraffic.com/hc/en-us/articles/360017183497-As-a-station-owner-am-I-entitled-to-a-free-Subscription-). These are all just examples: there is far more to Signal K Server. If you are a boat owner, you can easily run Signal K Server on a Victron Cerbo GX, RaspberryPi or similar hardware. To take full advantage, you will probably want to connect it to your boat network via NMEA 0183 or NMEA 2000 but it is not a requirement. ### Marine Vendors For Marine vendors who build marine hardware and software, for example those developing navigation, monitoring and tracking systems, Signal K Server is an opportunity to accelerate development and decrease time to market, by taking advantage of a proven, modern and extensible software platform that is open source and available with a permissive Apache 2.0 license. Signal K Server is implemented in Node.js and is easy to integrate into modern systems that run Linux derivatives. Signal K Server is already running inside products developed by Victron Energy, Airmar Technology and others. ### Software Developers & Boat Electronics Hobbyists There are many boaters who happen to be highly skilled software developers and engineers, who want to build software for themselves and share with others. If you are one of them, Signal K offers you a free, modern and open platform developed by boaters for other boaters like you. Signal K Server features an extensible [plugin framework](./docs/develop/plugins/README.md), [web applications](./docs/develop/webapps.md) as well as a rich set of [REST](https://signalk.org/specification/1.7.0/doc/rest_api.html) and [Streaming APIs](https://signalk.org/specification/1.7.0/doc/streaming_api.html). Signal K Server takes care of all the complicated parts of protocol decode, and conversions to and from NMEA2000, NMEA0183 and many more protocols. It can also act as data hub for additional sensors, see the [Signal K SensESP project](https://github.com/SignalK/SensESP) for [ESP32](https://en.wikipedia.org/wiki/ESP32). Signal K Server makes the data available in JSON format according to the [Signal K standard specification](https://signalk.org/specification/latest/). This allows developers to bypass all the hurdles typically encountered when wanting to implement something for a boat. [Getting started with a plugin](./docs/develop/plugins/README.md#getting-started-with-plugin-development) is surprisingly easy. ## Signal K Platform Signal K is more than just the Signal K Server, it is a comprehensive platform that encompasses three major components: 1. **The Signal K Data Standard**: an open marine data standard. It is a modern data format for marine use, suitable for WiFi, cellphones, tablets and the internet. It is built on standard web technologies including JSON, WebSockets and HTTP. More information on [https://signalk.org](https://signalk.org/index.html). 2. **Signal K Server**: Software in this GitHub repository and described in this document. Signal K server is a full stack application developed in Node.js. Its back-end multiplexes data from and to NMEA0183, NMEA 2000, Signal K and other marine protocols, as well as WiFi, LAN and Internet, and provides APIs and websockets for access and control. Its front-end provides an extensible web-based application allowing easy configuration and management of server functions and capabilities. 3. **Signal K Plugins and Webapps**: Built using the extensibility of Signal K Server with a plugin framework, allows developers to develop applications that easily integrate with Signal K server, extend its capabilities and publish them through npm. All published plugins become available in all existing Signal K server installations, which provides an easy distribution mechanism. ## Documentation, Community & Support [Documentation for Signal K Server](https://demo.signalk.org/documentation). See [Github Discussions](https://github.com/SignalK/signalk/discussions/) and [Discord (chat)](https://discord.gg/uuZrwz4dCS). There is a [Signal K Server FAQ Frequently Asked Questions](https://github.com/SignalK/signalk-server/wiki/FAQ:-Frequently-Asked-Questions) on the Wiki, including [How do I integrate with NMEA2000 (CAN bus)](https://github.com/SignalK/signalk-server/wiki/FAQ:-Frequently-Asked-Questions#how-do-i-integrate-with-nmea2000-can-bus). ## How to get Signal K Server? For the typical boater, not being a software developer nor electrical engineer, the best option is to get a (commercially available) product that already has Signal K Server inside. These are the currently available devices: - [Cerbo GX](https://www.victronenergy.com/panel-systems-remote-monitoring/cerbo-gx) and other GX Devices by Victron Energy ([see Venus OS Large manual](https://www.victronenergy.com/live/venus-os:large)) - [SmartBoat module](https://www.airmar.com/Catalog/SmartBoat-SmartFlex) by Airmar For a more technical DIY oriented boater, a RaspberryPi based setup offers a very cost-attractive alternative. Read [this FAQ entry](https://github.com/SignalK/signalk-server/wiki/FAQ:-Frequently-Asked-Questions#how-do-i-integrate-with-nmea2000-can-bus) to learn how to connect a RaspberryPi to an NMEA2000 network. These prebuilt images for RaspberryPis take away most of the complexity involved from the software side: - [BBN Marine OS](https://github.com/bareboat-necessities/lysmarine_gen#what-is-lysmarine-bbn-edition) - [OpenPlotter](https://openmarine.net/openplotter) by OpenMarine - [Venus OS for RaspberryPis](https://github.com/victronenergy/venus/wiki/raspberrypi-install-venus-image) by Victron Energy - [AvNav Headless/Touch](https://github.com/free-x/AvNav-Image) You can run Signal K Server in Docker: - [Docker quickstart instructions](https://github.com/SignalK/signalk-server/blob/master/docker/README.md#quickstart) Or in a Kubernetes cluster: - [Kubernetes quickstart instructions](https://github.com/SignalK/signalk-server/blob/master/kubernetes/README.md#quickstart) And an installer for Windows: - [https://github.com/SignalK/signalk-server-windows](https://github.com/SignalK/signalk-server-windows) Another level up, this document explains how to install Signal K Server, as well as its dependencies, on a RaspberryPi that is already running Raspberry Pi OS: - [Installation on a RaspberryPi](./docs/installation/raspberry_pi_installation.md) Last, here is how to install the Signal K Server application from NPM: Prerequisites: - Node.js version 24 with latest npm installed $ sudo npm install -g signalk-server Now you can start the server with sample data: - NMEA0183 sample data: `signalk-server --sample-nmea0183-data` - NMEA2000 sample data: `signalk-server --sample-n2k-data` To generate your own vessel settings file and configure the server to start automatically, run: $ sudo signalk-server-setup ## Configuration and use ### Opening the Signal K Server Admin UI For all described options of running Signal K Server, ie. on an Airmar Smartboat, a Victron Cerbo GX or a RaspberryPi, the way to configure it is via the Admin UI. Open the Admin UI by navigating to http://[ipaddress]:3000/. Here is what it will look like when opened up on a Victron Cerbo GX: ![image](https://user-images.githubusercontent.com/5200296/226478726-568d8ea3-5f46-4e7b-b964-4fdefb386c32.png) The top of the screen shows some actual stats. Below that is a pane showing all configured Connections & Plugins. These are the plugins shown in above screenshot: - `sk-to-nmea0183` is the plugin that makes navigation data available on WiFi and/or LAN (TCP); typically used by apps on phones and tablets. - `signalk-n2kais-nmea0183` is another plugin, does the same, but then for AIS data - `venus` is a plugin that connects to the data bus inside the Victron GX device - `n2k-on-ve.can-socket` is not a plugin but a data connection. This one defines the Signal K Server connection to the NMEA2000 CAN-bus port. ### Creating an admin account The first thing to do is create an admin account. This is done in the Settings -> Users page: ![image](https://user-images.githubusercontent.com/5200296/226754646-3bc60ddb-245a-4bd2-ab2f-b5539bdefa77.png) Besides recommended from a security point of view, setting an admin account also enables the Restart button. After creating the account, the server needs to be restarted. How to do that depends on how you are using Signal K Server: self installed from NPM, embedded on a commercial device or otherwise. Power cycling the device that Signal K Server is running on will always work. ### Setting up data connections This screenshot shows how to setup an NMEA0183 connection: ![image](https://user-images.githubusercontent.com/5200296/226479444-853570cb-83ea-4246-afbe-06cafd48d790.png) ### Installing Plugins and Webapps The Appstore menu is where to add, update and remove Plugins and Webapps: ![image](https://user-images.githubusercontent.com/5200296/226479620-303a2e6e-a4f7-4ecb-b1f1-a668fb147d23.png) The entries with the blue icons are Webapps. The entries with the green icons are Plugins. An internet connection is required for Signal K Server to fetch information about availble Plugins and webapps. Typically, plugins make for functionality such as protocol conversion. And Webapps provide a user interface, up to a fully featured Chartplotter that runs in a web browser: ![image](https://user-images.githubusercontent.com/5200296/226479871-6f3769af-4fa4-43d6-871f-4a54bec372fa.png) To install Plugins and Webapps, click the "Available" menu on the left. It will show a categorised list of all available Plugins: ![image](https://user-images.githubusercontent.com/5200296/226480596-f65f5429-57d5-4d31-bb13-615d5664e2c4.png) It is also possible to search for and browse Plugins and Webapps in the NPM registry: - [Plugins](https://www.npmjs.com/search?q=keywords%3Asignalk-node-server-plugin) - [Webapps](https://www.npmjs.com/search?q=keywords:signalk-webapp) ### Restart after Configuration Changes and Plugin/Webapp Installation Most configuration changes and installing add-ons from the App store require a server restart to take effect. See Restart button at the top right or restart the server manually (details depend on your setup). If the restart button is not showing, that is usually because security is not activate and there is no Admin user. ### Configuring Plugins After the restart, the new Plugin needs to be enabled and configured. See the Server -> Plugin Config menu: ![image](https://user-images.githubusercontent.com/5200296/226481818-18c5cbe1-9118-4555-ab8b-1622c3e9404b.png) ### Vessel Base Data and Server Settings ![image](https://user-images.githubusercontent.com/5200296/226482046-dfb759dc-abbb-4987-a810-a24b77d0927e.png) ![image](https://user-images.githubusercontent.com/5200296/226482099-b9dd46ff-72a6-44e4-b384-1d15a4621e63.png) You can change the admin application's top left logo by placing a SVG file named `logo.svg` in the settings directory (default: $HOME/.signalk/). You can also provide a minimized square version of the logo in a file named `logo-minimized.svg` that will be shown when the sidebar is minimized. ### Server Log If the Admin UI is available, go to Server -> Server Log to see the server's log. Different errors are logged there, so in case of trouble make sure to check not only the Admin UI but also the server log. To activate more details debug logging enter the the names of the components you want to debug. Some of the debug keys are listed with toggles to activate them. With the Remember debug setting enabled, the configured debug keys parameter is stored in a settings file, ie. survives a server restart. ![image](https://user-images.githubusercontent.com/5200296/227020518-ac8b4355-5902-45a5-9d6c-0e9d1dc9e630.png) To enable debugging without going through the Admin UI, see the file `~/.signalk/debug` and add the required debug keys there. For example: `@signalk/aisreporter,signalk-server:udp-provider`. ## Supported PGNs, sentences and more - NMEA2000 PGNs: Reading NMEA2000 data is done by [n2k-signalk](https://github.com/SignalK/n2k-signalk) via [canboatjs](https://github.com/canboat/canboatjs). [Canboat PGN database](https://canboat.github.io/canboat/canboat.html) - NMEA0183 sentences: [nmea0183-signalk](https://github.com/SignalK/signalk-parser-nmea0183) - TODO ADD OTHER SUPPORTED PROTOCOLS ## Development The documents provide more details about developing Webapps or Plugins for Signal K Server, as well as working on the server itself: - [Contributing to this repo](docs/develop/contributing.md) - [WASM Plugins](docs/develop/plugins/wasm/README.md) (Rust, AssemblyScript, Go) - [Webapps](docs/develop/webapps.md) - [Working with the Course API](docs/develop/rest-api/course_api.md) - [Working with the Resources API](docs/develop/rest-api/resources_api.md) - [Resource Provider Plugins](docs/develop/plugins/resource_provider_plugins.md) - [Security](docs/security.md) ## Sponsoring Signal K See Signal K on [Open Collective](https://opencollective.com/signalk). ## License Copyright [2015] [Fabian Tollenaar, Teppo Kurki and Signal K committers] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: docker/Dockerfile ================================================ ARG REGISTRY="cr.signalk.io" ARG BASE_IMAGE="24.04-22.x" FROM ${REGISTRY}/signalk/signalk-server-base:latest-${BASE_IMAGE} AS base USER node RUN mkdir -p /home/node/.signalk/ \ && mkdir -p /home/node/signalk/ WORKDIR /home/node/signalk RUN npm config rm proxy \ && npm config rm https-proxy \ && npm config set fetch-retries 5 \ && npm config set fetch-retry-mintimeout 60000 \ && npm config set fetch-retry-maxtimeout 120000 \ && npm cache clean -f FROM base AS tarballs_installed WORKDIR /home/node/signalk COPY --chown=node:node *.tgz . # Install all tarballs locally (not globally) to avoid permission issues # Install workspace packages first so they're available when installing signalk-server RUN npm init -y \ && workspace_tarballs=$(ls *.tgz 2>/dev/null | grep -v '^signalk-server-[0-9]' || true) \ && if [ -n "$workspace_tarballs" ]; then npm install $workspace_tarballs; fi \ && server_tarball=$(ls signalk-server-[0-9]*.tgz) \ && npm install "$server_tarball" --install-strategy=nested \ && if [ -d node_modules/@signalk ]; then \ mkdir -p node_modules/signalk-server/node_modules/@signalk/ \ && cp -rf node_modules/@signalk/* node_modules/signalk-server/node_modules/@signalk/ \ && rm -rf node_modules/@signalk/; \ fi \ && rm *.tgz FROM base # Copy locally installed modules from tarballs_installed stage COPY --from=tarballs_installed --chown=node:node /home/node/signalk /home/node/signalk COPY --chown=node:node --chmod=755 docker/startup.sh startup.sh EXPOSE 3000 ENV SKIP_ADMINUI_VERSION_CHECK=true WORKDIR /home/node/.signalk ENTRYPOINT ["/home/node/signalk/startup.sh"] ================================================ FILE: docker/Dockerfile_base_24.04 ================================================ FROM ubuntu:24.04 ARG NODE ENV DEBIAN_FRONTEND=noninteractive RUN userdel -r ubuntu \ && groupadd --gid 1000 node \ && useradd --uid 1000 --gid node --shell /bin/bash --create-home node COPY docker/avahi/avahi-dbus.conf docker/bluez/bluezuser.conf /etc/dbus-1/system.d/ RUN apt-get update \ && apt-get -y install --no-install-recommends git \ python3 python3-venv python3-pip build-essential avahi-daemon libnss-mdns \ sysstat procps nano curl libcap2-bin sudo dbus bluez \ && groupadd -r docker -g 991 \ && groupadd -r i2c -g 990 \ && groupadd -r spi -g 989 \ && usermod -a -G dialout,i2c,spi,netdev,docker node \ && chmod u+s /usr/bin/date \ && mkdir -p /var/run/dbus/ /var/run/avahi-daemon/ \ && chmod -R 777 /var/run/dbus/ /var/run/avahi-daemon/ \ && chown -R avahi:avahi /var/run/avahi-daemon/ RUN curl -fsSL https://deb.nodesource.com/setup_$NODE | bash - \ && apt-get -y install --no-install-recommends nodejs \ && npm config rm proxy \ && npm config rm https-proxy \ && npm config set fetch-retries 5 \ && npm config set fetch-retry-mintimeout 60000 \ && npm config set fetch-retry-maxtimeout 120000 \ && npm cache clean -f \ && npm install npm@latest -g \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* ================================================ FILE: docker/Dockerfile_base_alpine ================================================ ARG NODE FROM node:${NODE}-alpine COPY docker/avahi/avahi-dbus.conf docker/bluez/bluezuser.conf /etc/dbus-1/system.d/ RUN deluser node 2>/dev/null || true && \ delgroup node 2>/dev/null || true RUN apk add --no-cache \ sudo git bash \ python3 py3-virtualenv py3-pip build-base \ avahi avahi-compat-libdns_sd \ procps-ng nano curl libcap-setcap dbus bluez \ && addgroup -g 991 -S docker \ && addgroup -g 990 -S i2c \ && addgroup -g 989 -S spi \ && mkdir -p /var/run/dbus /var/run/avahi-daemon \ && chmod 777 /var/run/dbus /var/run/avahi-daemon \ && chown -R avahi:avahi /var/run/avahi-daemon RUN npm config rm proxy \ && npm config rm https-proxy \ && npm config set fetch-retries 5 \ && npm config set fetch-retry-mintimeout 60000 \ && npm config set fetch-retry-maxtimeout 120000 \ && npm cache clean -f \ && rm -rf /tmp/* /var/tmp/* RUN addgroup -g 1000 node \ && adduser -u 1000 -G node -s /bin/sh -D node \ && addgroup node dialout \ && addgroup node i2c \ && addgroup node spi \ && addgroup node netdev \ && addgroup node docker \ && echo 'node ALL=(ALL) NOPASSWD:/bin/date' >> /etc/sudoers WORKDIR /home/node USER node ================================================ FILE: docker/Dockerfile_rel ================================================ ARG REGISTRY="cr.signalk.io" ARG BASE_IMAGE="24.04-22.x" FROM ${REGISTRY}/signalk/signalk-server-base:latest-${BASE_IMAGE} USER node RUN mkdir -p /home/node/.signalk/ \ && mkdir -p /home/node/signalk/ WORKDIR /home/node/signalk RUN npm config rm proxy \ && npm config rm https-proxy \ && npm config set fetch-retries 5 \ && npm config set fetch-retry-mintimeout 60000 \ && npm config set fetch-retry-maxtimeout 120000 \ && npm cache clean -f ARG TAG # Install locally (not globally) to avoid permission issues # Copy @signalk packages and @mxtommy/kip to nested location where webapp discovery expects them RUN npm install signalk-server@$TAG \ && mkdir -p node_modules/signalk-server/node_modules/@signalk/ \ && cp -rf node_modules/@signalk/* node_modules/signalk-server/node_modules/@signalk/ \ && rm -rf node_modules/@signalk/ \ && mkdir -p node_modules/signalk-server/node_modules/@mxtommy/ \ && cp -rf node_modules/@mxtommy/kip node_modules/signalk-server/node_modules/@mxtommy/ \ && rm -rf node_modules/@mxtommy/; COPY --chown=node:node --chmod=755 docker/startup.sh startup.sh EXPOSE 3000 WORKDIR /home/node/.signalk ENTRYPOINT ["/home/node/signalk/startup.sh"] ================================================ FILE: docker/README.md ================================================ # General Release process first publishes the server's modules to npm. Docker images are then built using the just published npm packages. Images (including older versions) are available at [Docker Hub](https://hub.docker.com/r/signalk/signalk-server) and starting from v2 at [GitHub Container registry](https://github.com/orgs/SignalK/packages/container/package/signalk-server). Going forward **use the full image name, including the registry cr.signalk.io**. That address will be updated to redirect to the recommended registry where the latest released version can be found. Release images: - cr.signalk.io/signalk/signalk-server:latest - cr.signalk.io/signalk/signalk-server:``, e.g. `v2.16.0` ## Docker Images based on Ubuntu 24.04 LTS ### Node.js 22.x **Image tag:** `v2.16.0` (example version) **Supported architectures:** - `linux/amd64` - `linux/arm64` - `linux/arm/v7` ### Node.js 24.x **Image tag:** `v2.16.0-24.x` (example version with suffix -24.x) **Supported architectures:** - `linux/amd64` - `linux/arm64` **Not supported:** - `linux/arm/v7` ### Important Note Node.js version 24.x dropped support for the `linux/arm/v7` (ARMv7) architecture. This affects older hardware, particularly early Raspberry Pi models: **Affected devices:** - Raspberry Pi Model A and B (original) - Raspberry Pi Zero (original) - Raspberry Pi 2 Model B v1.1 (with BCM2836 processor) **Recommendation:** If you're using any of the affected devices, use the Node.js 22.x images instead of 24.x to maintain compatibility. # Quickstart You can start a local server on port 3000 with demo data with ``` docker run --init -it --rm --name signalk-server --publish 3000:3000 --entrypoint /home/node/signalk/node_modules/.bin/signalk-server cr.signalk.io/signalk/signalk-server --sample-nmea0183-data ``` For real use you need to persist /home/node/.signalk where the server's configuration is stored, with for example ``` docker run -d --init --name signalk-server -p 3000:3000 -v $(pwd):/home/node/.signalk cr.signalk.io/signalk/signalk-server ``` This will run the server as background process and current directory as the settings directory. You will be prompted to create admin credentials the first time you you access the configuration admin web UI. ## Docker Compose See `docker/docker-compose.yml` for reference / example if you want to use docker-compose. # Image details and used tags Signal K Server docker images are based on Ubuntu 24.04 LTS. During build process, Node.js is installed including tools required to install or compile plugins. Signal K supports mDNS from docker, uses avahi for e.g. mDNS discovery. All required avahi tools and settings are available for user `node`, also from command line. ## Directory structure - server files: `/home/node/signalk/` (local npm install) - settings files and plugins: `/home/node/.signalk/` You most probably want to mount `/home/node/.signalk` from the host or as a volume to persist your settings. **Note:** Signal K Server is installed locally (not globally with `npm -g`) in `/home/node/signalk/node_modules/`. This avoids permission issues when installing plugins and provides better isolation. ## Container Runtime Detection The server automatically detects which container runtime is being used (Docker, Podman, Kubernetes, etc.) and sets the `CONTAINER_RUNTIME` environment variable. Plugins can use this to adapt their behavior. Supported runtimes: `docker`, `podman`, `kubernetes`, `containerd`, `crio`, `lxc` ## Release images Release images `docker/Dockerfile_rel` are size optimized and there are only mandatory files in the images. During the release process updated npm packages in the server repo are built and published to npmjs. Release docker image is then built from the published npm packages like Signal K server is installed normally from npmjs. ## Development images Development images `docker/Dockerfile`include all files from the Signal K server repository's master branch and these images are targeted mainly for development and testing. Development images are built off the files in the repo, including the submodules from `packages` directory. Development images are tagged `` (mainly `master`) and `sha`: ``` docker run --init --name signalk-server -p 3000:3000 -v $(pwd):/home/node/.signalk cr.signalk.io/signalk/signalk-server:master ``` ## Building from source To build a docker image locally from source, first build and pack the server: ```sh npm install npm run build:all npm pack --workspaces npm pack ``` Then build the docker image: ```sh $ docker build -t signalk-server:master -f docker/Dockerfile . ``` Now you can run the local image: ```sh docker run --init --name signalk-server -p 3000:3000 -v $(pwd):/home/node/.signalk signalk-server:master ``` ================================================ FILE: docker/avahi/avahi-dbus.conf ================================================ ================================================ FILE: docker/bluez/bluezuser.conf ================================================ ================================================ FILE: docker/docker-compose.yml ================================================ version: '2.2' services: signalk-server: image: cr.signalk.io/signalk/signalk-server:latest container_name: signalk-server restart: unless-stopped # ---------------------- network_mode: host # network_mode: bridge # (1/3) If bridge-mode is used, then comment line abobe (host) and select/add needed ports settings # ports: # (2/3) # - "3000:3000" # (3/3) # ---------------------- # environment: # (1/5) SK ENV parameters # - PORT=3000 # (2/5) # - SSLPORT=3443 # (3/5) # - NMEA0183PORT=10110 # (4/5) # - TCPSTREAMPORT=8375 # (5/5) # ---------------------- volumes: - /dev:/dev # - $PWD/signalk_conf:/home/node/.signalk # uncomment and make signalk_conf -folder where .signalk is bind mounted # ---------------------- # - type: bind # (1/3) uncomment these 3 lines to control startup.sh outside container # source: $PWD/startup.sh # (2/3) # target: /home/node/signalk/startup.sh # (3/3) # ---------------------- entrypoint: sh /home/node/signalk/startup.sh privileged: true logging: options: max-size: 10m ================================================ FILE: docker/startup.sh ================================================ #!/usr/bin/env sh # Detect container runtime (only if not already set by user) if [ -z "$CONTAINER_RUNTIME" ]; then # File-based detection (most reliable) if [ -f /.dockerenv ]; then export CONTAINER_RUNTIME="docker" elif [ -f /run/.containerenv ]; then export CONTAINER_RUNTIME="podman" elif [ -n "$KUBERNETES_SERVICE_HOST" ]; then # Kubernetes sets this environment variable export CONTAINER_RUNTIME="kubernetes" else # Fallback: check cgroups and other markers if [ -f /proc/1/cgroup ]; then if grep -q '/docker' /proc/1/cgroup 2>/dev/null; then export CONTAINER_RUNTIME="docker" elif grep -q '/libpod' /proc/1/cgroup 2>/dev/null; then export CONTAINER_RUNTIME="podman" elif grep -q '/kubepods' /proc/1/cgroup 2>/dev/null; then export CONTAINER_RUNTIME="kubernetes" elif grep -q '/lxc' /proc/1/cgroup 2>/dev/null; then export CONTAINER_RUNTIME="lxc" elif grep -q '/containerd' /proc/1/cgroup 2>/dev/null; then export CONTAINER_RUNTIME="containerd" fi fi # Additional checks for CRI-O and other runtimes if [ -z "$CONTAINER_RUNTIME" ]; then if [ -d /var/run/crio ]; then export CONTAINER_RUNTIME="crio" elif [ -S /var/run/containerd/containerd.sock ]; then export CONTAINER_RUNTIME="containerd" fi fi fi fi # Set IS_IN_DOCKER for Admin UI (disables "Update Server" button in container) export IS_IN_DOCKER=true # Check if host D-Bus socket is mounted (rootless container scenario) # If mounted, we use the host's D-Bus/Avahi instead of starting our own if [ -S /run/dbus/system_bus_socket ] && dbus-send --system --dest=org.freedesktop.DBus --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames >/dev/null 2>&1; then echo "Using host D-Bus (socket mounted from host)" else echo "Starting container D-Bus and Avahi services" service dbus restart /usr/sbin/avahi-daemon -k 2>/dev/null /usr/sbin/avahi-daemon --no-drop-root & service bluetooth restart fi /home/node/signalk/node_modules/signalk-server/bin/signalk-server --securityenabled ================================================ FILE: docker/v2_demo/Dockerfile ================================================ # docker buildx build --platform linux/amd64 -f Dockerfile_heroku_api_demo -t registry.heroku.com/signalk-course-resources-api/web . && \ # docker push registry.heroku.com/signalk-course-resources-api/web && \ # heroku container:release web -a signalk-course-resources-api FROM signalk/signalk-server:resources_course_api USER root WORKDIR /home/node/signalk COPY startup_heroku_demo.sh startup.sh RUN chmod +x startup.sh COPY resources /home/node/.signalk/resources COPY resources-provider.json /home/node/.signalk/plugin-config-data/ COPY course-data.json /home/node/.signalk/plugin-config-data/ COPY serverState /home/node/.signalk/serverState RUN chown -R node /home/node/.signalk USER node ================================================ FILE: docker/v2_demo/course-data.json ================================================ { "configuration": { "notifications": {}, "calculations": { "method": "Rhumbline", "autopilot": true } }, "enabled": true } ================================================ FILE: docker/v2_demo/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78 ================================================ {"distance":18912,"name":"test route","description":"testing route stuff","feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[23.421658428594455,59.976383142599445],[23.39545298552773,59.964698713370666],[23.386547033272887,59.94553321282956],[23.349311506736232,59.92852692137802],[23.352379069279134,59.912782827217114],[23.420858546854152,59.91443887159909],[23.529026801965298,59.9327648091369]]},"properties":{},"id":""}} ================================================ FILE: docker/v2_demo/resources/routes/da825f6c-1ae9-4f76-abc4-df2866b14b78 ================================================ {"distance":18912,"name":"test route","description":"testing route stuff","feature":{"type":"Feature","geometry":{"type":"LineString","coordinates":[[23.421658428594455,59.976383142599445],[23.39545298552773,59.964698713370666],[23.386547033272887,59.94553321282956],[23.349311506736232,59.92852692137802],[23.352379069279134,59.912782827217114],[23.420858546854152,59.91443887159909],[23.529026801965298,59.9327648091369]]},"properties":{},"id":""}} ================================================ FILE: docker/v2_demo/resources/waypoints/ac3a3b2d-07e8-4f25-92bc-98e7c92f7f1a ================================================ { "name": "demo waypoint", "description": "", "feature": { "type": "Feature", "geometry": { "type": "Point", "coordinates": [ 23.455311064598344, 59.99716209068623 ] }, "properties": {}, "id": "" } } ================================================ FILE: docker/v2_demo/resources/waypoints/afe46290-aa98-4d2f-9c04-d199ca64942e ================================================ { "name": "lock", "description": "this is the lock", "feature": { "type": "Feature", "geometry": { "type": "Point", "coordinates": [ 23.435321561218167, 59.98480312764812 ] }, "properties": {}, "id": "" }, "timestamp": "2022-04-21T18:23:19.815Z", "$source": "resources-provider" } ================================================ FILE: docker/v2_demo/resources-provider.json ================================================ { "configuration": { "standard": { "routes": true, "waypoints": true, "notes": true, "regions": true }, "custom": [], "path": "./resources" } } ================================================ FILE: docker/v2_demo/serverstate/course/settings.json ================================================ { "activeRoute": { "href": "/resources/routes/ad825f6c-1ae9-4f76-abc4-df2866b14b78", "startTime": "2022-04-21T18:40:44.319Z", "pointIndex": 3, "pointTotal": 7, "reverse": true }, "nextPoint": { "href": null, "type": "RoutePoint", "position": { "latitude": 59.92852692137802, "longitude": 23.349311506736232 }, "arrivalCircle": 500 }, "previousPoint": { "href": null, "type": "RoutePoint", "position": { "longitude": 23.485033333333334, "latitude": 60.033516666666664 } } } ================================================ FILE: docker/v2_demo/startup_heroku_demo.sh ================================================ #!/usr/bin/env sh service dbus restart /usr/sbin/avahi-daemon -k /usr/sbin/avahi-daemon --no-drop-root & /home/node/signalk/bin/signalk-server --sample-nmea0183-data ================================================ FILE: docs/README.md ================================================ --- title: Introduction --- # Introduction Signal K Server is software designed to be deployed on a vessel to act as a central hub which: 1. Collects data from devices and sensors on board 1. Aggregates and exposes it using the _[Signal K Data Standard](https://signalk.org/specification/latest/)_ 1. Exposes the collected data via REST APIs and websocket protocols over a standard WiFi, LAN or Internet connection. Through implementation of the _[Signal K Data Standard](https://signalk.org/specification/latest/)_, it enables data exchange between NMEA0183, NMEA2000 and other marine protocols facilitating two way communication between the various onboard systems. In addition it can also act as data hub for additional sensors ensuring their data appears within the single data model. _(Visit the [Signal K SensESP project](https://github.com/SignalK/SensESP) for [ESP32](https://en.wikipedia.org/wiki/ESP32) for details.)._ Data is made available to client applications / connections in JSON format making it widely accessible to Apps on phone / tablet devices and web applications. Signal K Server is also extensible, providing a plugin framework which allows developers to create solutions that integrate and extend its capabilities. These solutions can be published to **npmjs** and installed via the **App Store** in the server's web-based user interface. ![Server only setup](img/server_only.svg) ================================================ FILE: docs/breaking_changes.md ================================================ --- title: Breaking Changes --- # Breaking Changes & Deprecations This document lists breaking changes and deprecations in Signal K Server. --- ## Node.js: Default Version Updated to 24 Signal K Server now defaults to **Node.js 24** and requires **Node.js 22 or later**. ### What Changed | Setting | Before | After | | ---------------- | ------ | ----- | | Recommended | 22 | 24 | | Minimum required | 20 | 22 | ### Dropped Platform Support Node.js 24 drops support for the following platforms: - **armv7** (32-bit ARM) — Affects older Raspberry Pi models (Pi 2, Pi Zero/Zero W). Use a 64-bit OS on Pi 3/4/5 or stay on an older Signal K Server version. - **Windows x86** (32-bit Windows) — Use 64-bit Windows instead. ### Action Required - Update your Node.js installation to version 22 or later (version 24 recommended) - If running on armv7 or Windows x86, you must migrate to a supported platform or remain on the previous Signal K Server version --- ## Admin UI: React 19 Migration The Admin UI has been upgraded from React 16 to **React 19**. This is a significant update that may affect embedded webapps and plugin configuration panels. ### What Changed | Component | Before | After | | ------------ | ---------- | ---------- | | React | 16.14.0 | 19.x | | React DOM | 16.14.0 | 19.x | | React Router | 4.x | 6.x | | Language | JavaScript | TypeScript | ### Impact on Embedded Webapps **If your webapp uses Module Federation to share React with the Admin UI:** 1. **Singleton sharing is now required** - Your webapp must configure React and ReactDOM as singletons with `requiredVersion: false`. See [vite.config.js](https://github.com/SignalK/signalk-server/blob/master/packages/server-admin-ui/vite.config.js) for the current configuration. 2. **React 19 compatibility** - If your webapp bundles its own React, it should be compatible with components rendered by the host. Most React 16/17/18 code works unchanged in React 19, but some deprecated APIs have been removed. 3. **String refs removed** - React 19 no longer supports string refs (`ref="myRef"`). Use `useRef()` instead. 4. **`defaultProps` on function components** - Deprecated. Use JavaScript default parameters instead. ### Impact on Plugin Configuration Panels Plugin configuration panels using `./PluginConfigurationPanel` export continue to work. The props interface remains the same: - `configuration` - the plugin's configuration data - `save` - function to save configuration ### No Impact - **Standalone webapps** - Webapps that don't use Module Federation sharing are not affected - **Server APIs** - All Signal K HTTP and WebSocket APIs remain unchanged - **Plugin JavaScript APIs** - Server-side plugin APIs are not affected --- ## Security: Anonymous Read Access Disabled by Default When security is first enabled on a new installation, `allow_readonly` now defaults to `false`. Previously it defaulted to `true`, meaning anyone could read all Signal K data without authentication. ### Impact - **New installations** will require authentication for all access, including read-only. Devices like chart plotters and instrument displays that previously worked without a token will need to be configured with access credentials. - **Existing installations** are **not affected** — the `allow_readonly` value is already written explicitly in `security.json` and will be preserved. ### Mitigation During initial security setup, the Enable Security dialog offers an **"Allow Readonly Access"** checkbox to opt in. Alternatively, you can enable it at any time in **Security > Settings**. --- ## REST API Changes The following changes have been implemented with the introduction of **Resources API** and apply to applications using the `./signalk/v2/resources` endpoint. _Note: These changes DO NOT impact applications using the `./signalk/v1/resources` endpoint._ ### 1. Resource ID prefix assignment The version 1 specification defined resource Ids with the following format `urn:mrn:signalk:uuid:`. _e.g. `urn:mrn:signalk:uuid:18592f80-3425-43c2-937b-0be64b6be68c`_ The Resource API has dropped the use the prefix and ids are now just a uuidv4 value. _e.g. `18592f80-3425-43c2-937b-0be64b6be68c`_ This format is used for both accessing a resource _e.g. `/signalk/v1/api/resources/waypoints/18592f80-3425-43c2-937b-0be64b6be68c`_ as well as the value within an `href` attribute. _Example:_ ``` { "name": "...", "descripton": "...", "href": "/resources/waypoints/18592f80-3425-43c2-937b-0be64b6be68c", ... } ``` ### 2. Resource Attributes The Resources API has updated the definition of the following resources and may break applications that simply shift to using the `v2` api without catering for the changes: - **routes**: removed the `start`, `end` properties. - **waypoints**: removed `position` attribute, added `name`, `description` and `type` attributes. - **regions**: removed `geohash` attribute, added `name` and `description` properties. - **notes**: removed `geohash` and `region` attributes, added `href` and `properties` attributes. - **charts**: There has been a significant changes to include support for WMS, WMTS and TileJSON sources. Please see the [Resources OpenAPI definition](https://github.com/SignalK/signalk-server/blob/master/src/api/resources/openApi.json) for details. --- ## Deprecations: ### 1. courseGreatCircle, courseRhumbline paths With the introduction of the Course API the following paths should now be considered deprecated: - `/signalk/v1/api/vessels/self/navigation/courseGreatCircle` - `/signalk/v1/api/vessels/self/navigation/courseRhumbline` _Note: The Course API does currently maintain values in these paths for the purposes of backward compatibility, but applications and plugins referencing these paths should plan to move to using the equivalent paths under `/signalk/v2/api/vessels/self/navigation/course`._ ================================================ FILE: docs/develop/README.md ================================================ --- title: Developing children: - ../whats_new.md - ../breaking_changes.md - plugins/README.md - rest-api/README.md - ../guides/unitpreferences.md --- # Developing with Signal K Signal K Server has an extensible architecture that enables developers to add functionality to support new protocols, devices, information sources, etc. The information in this section aims to provide guidance, not only on how to develop plugins and applications to extend capability, but also how to do so in alignment with the Signal K specification, protocol and server architecture. By understanding the underlying architecture, the plugins and apps you create will ensure that the additional functionality and data will be discoverable and work in harmony with other solutions. See also the [contributing document](https://github.com/SignalK/signalk-server/blob/master/contributing.md) for instructions on contributing to the server's code. ## Looking Ahead Signal K Server v2 marks the start of an evolution from the Signal K v1 approach of defining the paths, their hierarchy and the full data model schema, towards an approach centered around modular REST APIs (HTTP endpoints defined as OpenApi specifications). These APIs enact operations (i.e. activate a route, advance to next point, etc) rather just expose a generic data model with some well known paths. They are available under the path `/signalk/v2/api` so they can coexist with v1 APIs. There is a connection with the Signal K full data model but, unlike the v1 APIs it is not 1:1, it is abstracted behind the interface. The reason for adopting this approach is to address the fact that many paths within a Signal K hierarchy are related, a change in the value of one path will require that the value of other paths be updated to ensure that the data model is consistent. At present this relies on the plugin / application knowing which paths in the hierarchy are related. Additionally there may be other plugins / applications also updating some of the same paths which can cause the data model to become invalid, which then erodes trust in the data which impacts its use in navigation. The v1 model for using PUT handlers is also very vague and causes confusion. The aim of defining APIs with clear contracts using industry standard OpenApi mechanism is to make APIs discoverable and their use and semantics explicit. The use of APIs to perform operations addresses these issues providing the following benefits: 1. A standardised interface for all applications / plugins to perform an operation 1. Provides clear ownership of the paths in the Signal K data model 1. Ensures values are being maintained in all of the related paths. 1. Increases trust in the data for use in all scenarios. ### Stream Interface Currently, when v2 REST APIs emit deltas that contain v2 paths and structure, but they do not end up in the full model. This means that these paths and values are only available via API GET requests. ## Offline Use When operating on a vessel you should not assume that a connection to Internet services is available. Therefore, it is important that the WebApps and Plugins you create be _"self contained"_ and provide all the resources they require to operate _(i.e. fonts, stylesheets, images, etc)_. This also minimises data charges even if your module does use data over Internet. For WebApps and Plugins that do connect to Internet based services to provide data, they should be resilient to changes in the connection status to those services and where necessary display their status. ## Deprecations and Breaking Changes With the move towards REST APIs and the desire to improve the data model (and also fix some mistakes) it's inevitable that there will be deprecations and breaking changes. For example, when addressing duplicate Great Circle and Rhumbline course paths, the resultant changes will likley break compatibility with v1. For details about paths that are flagged for deprecation see [Changes & Deprecations](../breaking_changes.md). ================================================ FILE: docs/develop/plugins/README.md ================================================ --- title: Plugins children: - ../webapps.md - wasm/README.md - deltas.md - configuration.md - backpressure.md - autopilot_provider_plugins.md - course_calculations.md - resource_provider_plugins.md - weather_provider_plugins.md - custom_renderers.md - publishing.md - ci.md - release.md --- # Server plugins Signal K Node server plugins are components that extend functionality of the server. They are installed via the AppStore and configured via the Admin UI. Signal K server exposes an interface for plugins to use in order to interact with the full data model, emit delta messages and process requests. ## Plugin Types Signal K supports two types of plugins: - **JavaScript Plugins** - Traditional JavaScript/TypeScript plugins (documented below) - **[WASM Plugins](./wasm/README.md)** - Plugins written in Rust, AssemblyScript, Go, or other WASM-compatible languages [WASM](https://en.wikipedia.org/wiki/WebAssembly) is short for WebAssembly. WASM is a runtime for executing portable code in near native speeds and in isolation. WASM plugins offer sandbox isolation, memory safety, and the ability to use languages other than JavaScript. See the [WASM Plugins documentation](./wasm/README.md) for details. ## Node.js Plugin Capabilities Plugins can: - Expose _[REST APIs](../rest-api/README.md)_ to provide consumers/clients a way to perform operations offered by your plugin. The APIs will be published under `http://{skserver}:3000/plugins/{pluginId}`. - Provide a webapp by placing the relevant files in a folder named `/public/` which the server will mount under `http://{skserver}:3000/{pluginId}`. **Note: With the move towards Signal K server providing APIs to perform operations, it is important that you consider how the proposed functionality provided by your plugin aligns with the Signal K architecture before starting development.** For example, if the plugin you are looking to develop is providing access to information such as `route,` `waypoint`, `POI`, or `charts` you should be creating a _[Resources Provider Plugin](./resource_provider_plugins.md)_ for the _[Resources API](../rest-api/resources_api.md)_. Or if you are looking to perform course calculations or integrate with an autopilot, you will want to review the _[Course API](../rest-api/course_api.md)_ documentation prior to commencing your project. **OpenApi description for your plugin's API** If your plugin provides an API you should consider providing an OpenApi description. This promotes cooperation with other plugin/webapp authors and also paves the way for incorporating new APIs piloted within a plugin into the Signal K specification. _See [Add OpenAPI definition](#add-an-openapi-definition)_ below. --- ## Getting Started with Plugin Development ### Prerequisites To get started developing your plugin you will need the following: - Signal K server instance on your device _(clone of GIT repository or docker instance)_ - NodeJs version 20 or later and NPM installed - SignalK server configuration folder. _(Created when Signal K server is started. default location is `$HOME/.signalk`)_. --- ### Setting up your project 1. Create a folder for your plugin code and create the necessary file structure: ```shell mkdir my-plugin cd my-plugin npm init # create package.json file ``` 2. Create the folders to hold your plugin code and webapp UI. ```shell /my-plugin /plugin # plugin (javascript code / built typesrcipt code) index.js .. /public # web app UI index.html .. /src # typescript source code (not required if using javascript) index.ts ... package.json ``` 3. Update the `package.json` to reflect your project structure and add keywords to identify the package for the Signal K AppStore. ```JSON { "name": "my-plugin", "version": "1.0.0", "description": "My signalk plugin", "keywords": [ "signalk-node-server-plugin", "signalk-category-ais" ], "signalk-plugin-enabled-by-default": false, "signalk": { "appIcon": "./assets/icons/icon-72x72.png", "displayName": "My Great WebApp" }, "main": "plugin/index.js", ... } ``` 4. _Optional:_ Install any dependencies or third party packages. ```shell npm i ``` ### Link your project to Signal K server. Once you have developed your plugin code and are ready to debug, the most convenient way is to use `npm link` to link your plugin code to your instance of Signal K server. To do this, from within a terminal window (if you are using Docker, the following must be executed from the container terminal): ```shell # Ensure you are in the folder containing your built plugin code cd my_plugin_src # Create a link (may require the use of sudo) npm link # Change to the Signal K server configuration directory cd ~/.signalk # Link your plugin using the name in the package.json file #(may require the use of sudo) npm link my-signalk-plugin-app ``` When you start Signal K server the plugin will now appear in the **Plugin Config** screen where it can be configured and enabled. Updating and/or installing new plugins will remove the link and you need to re-link your plugin. ### Debugging The simplest way to debug your plugin is to turn on **Enable Debug log** for your plugin in the **Plugin Config** screen. Alternatively, you can debug your plugin by starting the Signal K server with the `DEBUG` environment variable: ```shell $ DEBUG=my-signalk-plugin signalk-server # sample output my-signalk-plugin Plugin stopped +0ms my-signalk-plugin Plugin started +2ms ``` You can also view debug information about the plugin loading process: ```shell $ DEBUG=signalk:interfaces:plugins signalk-server # sample output signalk:interfaces:plugins Registering plugin my-signalk-plugin +0ms signalk:interfaces:plugins Could not find options for plugin my-signalk-plugin, returning empty options: +2ms ``` #### Sample Data For development purposes, it's often nice to have some mocked data. SignalK comes with a synthesized NMEA2000 data set that can be used as sample data. You can enable this by adding `--sample-n2k-data` to the command line: ```shell $ DEBUG=my-signalk-plugin signalk-server --sample-n2k-data ``` --- ## Start Coding Signal K server plugins are NodeJs `javascript` or `typescript` projects that return an object that implements the {@link @signalk/server-api!Plugin | Plugin} interface. They are installed into the `node_modules` folder that resides inside the SignalK server's configuration directory _(`$HOME/.signalk` by default)_. A Signal K plugin is passed a reference to the Signal K server plugin interface which it can use to interact with the server. Following are code snippets that can be used as a template for plugin development ensuring the returned Plugin object contains the required functions. ### Javascript Create `index.js` with the following content: ```javascript module.exports = (app) => { const plugin = { id: 'my-signalk-plugin', name: 'My Great Plugin', start: (settings, restartPlugin) => { // start up code goes here. }, stop: () => { // shutdown code goes here. }, schema: () => { properties: { // plugin configuration goes here } } } return plugin } ``` ### Typescript Create `index.js` with the following content: ```typescript import { Plugin, ServerAPI } from '@signalk/server-api' const start = (app: ServerAPI): Plugin => { const plugin: Plugin = { id: 'my-signalk-plugin', name: 'My Great Plugin', start: (settings, restartPlugin) => { // start up code goes here. }, stop: () => { // shutdown code goes here. }, schema: () => { properties: { // plugin configuration goes here } } } return plugin } module.exports = start ``` A plugin must return an object containing the following functions: - `start(settings, restartPlugin)`: This function is called when the plugin is enabled or when the server starts (and the plugin is enabled). The `settings` parameter contains the configuration data entered via the **Plugin Config** screen. `restartPlugin` is a function that can be called by the plugin to restart itself. - `stop()`: This function is called when the plugin is disabled or after configuration changes. Use this function to "clean up" the resources consumed by the plugin i.e. unsubscribe from streams, stop timers / loops and close devices. If there are asynchronous operations in your plugin's stop implementation you should return a Promise that resolves when stopping is complete. - `schema()`: A function that returns an object defining the schema of the plugin's configuration data. It is used by the server to generate the user interface in the **Plugin Config** screen. _Note: When a plugin's configuration is changed the server will first call `stop()` to stop the plugin and then `start()` with the new configuration data. Return a Promise from `stop` if needed so that `start` is not called before stopping is complete._ A plugin can also contain the following optional functions: - `uiSchema()`: A function that returns an object defining the attributes of the UI components displayed in the **Plugin Config** screen. - `registerWithRouter(router)`: This function (which is called during plugin startup) enables plugins to provide an API (REST API in our context) by registering paths with the Express router. The APIs will be published under `http://{skserver}:3000/plugins/{pluginId}{path string}`. It is strongly recommended that the plugin implement `getOpenAPI()` to publish API documentation if this function is used. _Example:_ ```javascript plugin.registerWithRouter = (router) => { router.get('/preferences', (req, res) => { // URL will be http://{skserver}:3000/plugins/{pluginId}/preferences res.status(200).json({ preferences: { color: 'red', speed: 1.23 } }) }) } ``` - `getOpenApi()`: Function to return the OpenAPI definition. This should be implemented when your plugin provides HTTP endpoints for clients to call. Doing so makes the OpenAPI definition available in the server Admin UI under `Documentation -> OpenAPI`. _Example:_ ```javascript const openapi = require('./openApi.json') plugin.getOpenApi = () => openapi ``` --- ## Add an OpenAPI Definition If your plugin exposes an API to interact with it then you should include an OpenAPI definition. You do this by creating an OpenAPI definition within the file `openApi.json` and then returning the content of the file with the `getOpenApi` method. _Example:_ ```javascript const openapi = require('./openApi.json'); ... plugin.getOpenApi = () => openapi; ``` This will include your plugin's OpenApi definition in the documentation in the server's Admin UI under _Documentation -> OpenAPI_. Note: If the plugin's OpenApi description DOES NOT include a `servers` property, the API path presented in the documentation will be relative to the Signal K API path. You should include this property the plugin API is rooted elsewhere. _Example:_ ```JSON "servers": [ { "url": "/myapi/endpoint" } ], ``` See [testplugin](https://github.com/SignalK/signalk-server/tree/b82477e63ebdc14878164ce1ed3aedd80c5a8b0c/test/plugin-test-config/node_modules/testplugin) for an example. --- ## Logging To record deltas sent by the plugin in the server's data log, enable the **Log plugin output** in the plugin configuration screen. --- ## Removing a plugin Plugins can be removed via the AppStore. You can also remove a plugin manually by: 1. Deleting it's folder under `~/.signalk/node_modules` 1. Deleting it's entry from `~/.signalk/package.json` 1. Run `npm prune` from the `~/.signalk/` directory. Alternatively you can: 1. Remove the folder `~/.signalk/node_modules` 1. Run `npm install` from the `~/.signalk/` directory. Finally you can remove the plugin setting file in `~/.signalk/plugin-config-data/`. --- ## Examples Following are links to some published SignalK plugins that serve as an example of working plugins: - [set-system-time](https://github.com/SignalK/set-system-time/blob/master/index.js) - [Ais Reporter](https://github.com/SignalK/aisreporter/issues) ================================================ FILE: docs/develop/plugins/autopilot_provider_plugins.md ================================================ --- title: Autopilot Providers --- # Autopilot Provider plugins The Signal K [Autopilot API](../rest-api/autopilot_api.md) provides a way for all Signal K clients to perform common autopilot operations independent of the autopilot device in use. The API is defined in an [OpenAPI](/doc/openapi/?urls.primaryName=autopilot) document. Requests made to the Autopilot API are received by the Signal K Server, where they are validated and an authorisation check performed, before being passed on to a **provider plugin** to action the request on the autopilot device. This de-coupling of request handling and autopilot communication provides the flexibility to support a variety of autopilot devices and ensures interoperability and reliabilty. Autopilot API requests are passed to a **provider plugin** which will process and action the request facilitating communication with the autopilot device. The following diagram provides an overview of the Autopilot API architectue. _Autopilot API architecture_ ## Provider Plugins An autopilot provider plugin is a Signal K server plugin that implements the {@link @signalk/server-api!AutopilotProvider | `AutoPilotProvider`} interface, which: - Tells server the autopilot devices provided for by the plugin - Registers the methods used to action requests passed from the server to perform autopilot operations. Multiple providers can be registered and each provider can manage one or more autopilot devices. **Note: An Autopilot Provider plugin MUST:** - Implement all Autopilot API interface methods. - Facilitate communication on the target autopilot device to send commands and retrieve both status and configuration information - Ensure the `engaged` path attribute value is maintained to reflect the operational status of the autopilot. - Map the `engage` and `disengage` operations to an appropriate autopilot device `state`. - Set the state as `off-line` if the autopilot device is not connected or unreachable. - Set the mode as `dodge` when the autopilot device is is in dodge mode. ## Registering as an Autopilot Provider A provider plugin must register itself with the Autopilot API during start up by calling the {@link @signalk/server-api!ServerAPI.registerAutopilotProvider | `registerAutopilotProvider`}. _Example: Plugin registering as an autopilot provider._ ```javascript import { AutopilotProvider } from '@signalk/server-api' module.exports = function (app) { const plugin = { id: 'mypluginid', name: 'My autopilot Provider plugin' } const autopilotProvider: AutopilotProvider = { getData: (deviceId) => { return ... }, getState: (deviceId) => { return ... }, setState: (state, deviceId) => { ... }, getMode: (deviceId) => { return ... }, setMode: (mode, deviceId) => { ... }, getTarget: (deviceId) => { return ... }, setTarget(value, deviceId) => { ... }, adjustTarget(value, deviceId) => { ... }, engage: (deviceId) => { ... }, disengage: (deviceId) => { ... }, tack:(direction, deviceId) => { ... }, gybe:(direction, deviceId) => { ... }, dodge:(value, deviceId) => { ... } } const pilots = ['pilot1', 'pilot2'] plugin.start = function(options) { ... try { app.registerAutopilotProvider(autopilotProvider, pilots) } catch (error) { // handle error } } return plugin } ``` ## Sending Updates and Notifications The Autopilot API is responsible for sending both update and notification `deltas` to Signal K clients. Data received from an autopilot device, regardless of the communications protocol (NMEA2000, etc), should be sent to the Autopilot API by calling the {@link @signalk/server-api!ServerAPI.autopilotUpdate | `autopilotUpdate` } method. This will ensure: - Default pilot status is correctly maintained - `steering.autopilot.*` both V1 and V2 deltas are sent > [!IMPORTANT] > The values provided via `autopilotUpdate` will be sent in the relevant delta message, so ensure they are in the correct units (e.g. angles in radians, etc). See {@link AutopilotApi} for details. _Example Update:_ ```javascript app.autopilotUpdate('my-pilot', { target: 1.52789, mode: 'compass' }) ``` Notifications / Alarms are sent using one of the normalised alarm names below as the path and a `Notification` as the value. - waypointAdvance - waypointArrival - routeComplete - xte - heading - wind _Example Notification:_ ```javascript app.autopilotUpdate('my-pilot', { alarm: { path: 'waypointAdvance', value: { state: 'alert' method: ['sound'] message: 'Waypoint Advance' } } }) ``` ## Unhandled Operations A provider plugin **MUST** implement **ALL** Autopilot API interface methods, regardless of whether the operation is supported or not. For an operation that is not supported by the autopilot device, then the plugin should `throw` an exception. _Example:_ ```typescript { // unsupported operation method definition async gybe(d, id) { throw new Error('Unsupprted operation!) } } ``` ================================================ FILE: docs/develop/plugins/backpressure.md ================================================ --- title: Connection Backpressure --- # Connection Backpressure Signal K Server includes automatic backpressure handling to gracefully manage slow client connections on WebSocket, Signal K TCP (port 8375), and NMEA TCP (port 10110) interfaces. This document explains how to detect and handle backpressure events in your webapp or plugin. ## Overview When a client connection can't keep up with the data rate, the server enters "backpressure mode" for that connection: 1. Instead of queuing more data (which would consume server memory), the server keeps only the **latest value** for each path 2. When the client catches up, accumulated values are sent in a single delta with a `$backpressure` indicator 3. Normal operation resumes automatically ## Detecting Backpressure When the server flushes accumulated values after a backpressure period, it adds a `$backpressure` property to the delta: ```javascript ws.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.$backpressure) { // This delta contains accumulated values from a backpressure period console.warn( `Backpressure: ${msg.$backpressure.accumulated} paths accumulated over ${msg.$backpressure.duration}ms` ) // Show a user notification showNetworkWarning( 'Network congestion detected - some updates were skipped' ) } // Process delta normally - values are still valid (just the latest ones) handleDelta(msg) } ``` ## Delta Format Normal delta: ```json { "context": "vessels.urn:mrn:imo:mmsi:123456789", "updates": [ { "$source": "n2k-01.115", "timestamp": "2024-01-15T10:30:00.000Z", "values": [ { "path": "navigation.position", "value": { "latitude": 60.0, "longitude": 25.0 } } ] } ] } ``` Backpressure flush delta: ```json { "context": "vessels.urn:mrn:imo:mmsi:123456789", "updates": [...], "$backpressure": { "accumulated": 42, "duration": 1250 } } ``` ## Properties | Property | Type | Description | | --------------------------- | ------ | ------------------------------------------------------------------------ | | `$backpressure.accumulated` | number | Number of unique context:path:$source combinations that were accumulated | | `$backpressure.duration` | number | Milliseconds the server was in backpressure mode for this client | ## Connection Types | Interface | Strategy | Details | | ------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------- | | WebSocket (Primus) | Accumulate + flush | Keeps latest value per path, flushes with `$backpressure` indicator when client catches up | | Signal K TCP (port 8375) | Accumulate + flush | Same behavior as WebSocket for subscription deltas | | NMEA TCP (port 10110) | Drop + disconnect | NMEA sentences are stateless; sentences are dropped for slow clients, connection terminated if buffer stays over hard limit | | TCP client (outbound) | Drop | Outbound writes are skipped when the remote server's buffer is full | WebSocket, Signal K TCP, and NMEA TCP connections share the configurable thresholds `BACKPRESSURE_ENTER`, `MAXSENDBUFFERSIZE`, and `MAXSENDBUFFERCHECKTIME`. The TCP client (outbound) uses only `BACKPRESSURE_ENTER` for write gating. ## Important Notes - **Values are correct** - the delta contains the latest values, only intermediate updates were dropped - **Per-connection** - backpressure is specific to each connection, not server-wide - **Automatic recovery** - the server exits backpressure mode as soon as the client catches up - **Consider reducing scope** - if backpressure is frequent, consider using granular subscriptions to reduce data volume ## Best Practices 1. **Show a non-blocking warning** - inform users without interrupting their workflow 2. **Auto-dismiss** - clear the warning after a timeout (10 seconds is reasonable) 3. **Don't panic** - backpressure is graceful degradation, not an error 4. **Log for debugging** - record backpressure events to help diagnose network issues ## Testing Environment variables for testing (not recommended for production): ```bash # Lower thresholds for testing BACKPRESSURE_ENTER=1024 # Enter backpressure at 1KB (default: 512KB) BACKPRESSURE_EXIT=0 # Exit when buffer is empty (default: 1KB) ``` ================================================ FILE: docs/develop/plugins/ci.md ================================================ --- title: Plugin CI/CD --- # Continuous Integration for Plugins Signal K provides a reusable GitHub Actions workflow that tests your plugin across all platforms where Signal K server runs. Even plugins without a test suite benefit — the workflow validates your plugin's structure, entry point, configuration schema, lifecycle, and API usage. ## Quick Start Create `.github/workflows/signalk-ci.yml` in your plugin repository: ```yaml name: SignalK Plugin CI on: push: branches: [main, master] pull_request: branches: [main, master] jobs: test: uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master ``` Push to GitHub — your plugin is now tested on Linux (x64 + arm64), macOS, Windows, and armv7 (Cerbo GX). ## Manual Trigger with Custom Settings Add `workflow_dispatch` to get a **"Run workflow"** button in the GitHub Actions UI where you can override Node versions, toggle armv7/Cerbo GX testing, enable integration tests, and more — without editing your workflow file. Because `workflow_call` and `workflow_dispatch` inputs are separate namespaces in GitHub Actions, the workflow needs two jobs: one for automatic runs (push/PR) with hardcoded defaults, and one for manual runs that passes through your form inputs. See [`examples/plugin-caller-example.yml`](examples/plugin-caller-example.yml) for the full workflow with manual trigger support. ## What Gets Tested ### Platforms | Platform | Architecture | Node versions | Notes | | -------- | ---------------- | ------------- | ------------------------------------------------ | | Linux | x64 | 22, 24 | GitHub-hosted runner | | Linux | arm64 | 22, 24 | GitHub-hosted runner — Raspberry Pi 4/5 | | macOS | arm64 | 22, 24 | GitHub-hosted runner | | Windows | x64 | 22, 24 | GitHub-hosted runner | | Linux | armv7 (Cerbo GX) | 20 | QEMU emulation — matches Venus OS 3.70 (Node 20) | ### Validation Checks The desktop jobs (Linux, Linux arm64, macOS, Windows) run these checks, even if your plugin has no test suite. The list below is a summary for readers — the authoritative source for what the CI actually validates is the workflow itself: [.github/workflows/plugin-ci.yml](https://github.com/SignalK/signalk-server/blob/master/.github/workflows/plugin-ci.yml). **package.json** — `signalk-node-server-plugin` keyword, `main` or `exports` field, `engines.node` declaration **Entry point** — After build, verifies the plugin exports a constructor function **plugin.schema()** — Calls `schema()` and checks it returns a JSON-serializable schema-like object without crashing (not fully validated against the JSON Schema meta-schema) **Lifecycle** — Runs `start()` → `stop()` → `start()` (restart) with an empty configuration. Validates delta messages emitted during startup and checks that `registerDeltaInputHandler` handlers forward deltas correctly. **API usage** — Scans source files for: - Deprecated APIs (`setProviderStatus` → `setPluginStatus`, `setProviderError` → `setPluginError`) - Internal server properties (`app.server`, `app.deltaCache`, `app.pluginsMap`) - Route registration anti-patterns (direct `app.get()` instead of `registerWithRouter()`) - File storage anti-patterns (writing to `__dirname` or `process.cwd()` instead of `app.getDataDirPath()`) - Security anti-patterns (accessing `app.securityStrategy` or `isDummy()` — plugin routes are already protected by the server) - Node built-in module version mismatches (`node:sqlite` requires `engines.node >= 22.5.0`) **npm pack** — Verifies all files referenced by `main`/`exports` are included in the published package **App Store compatibility** — Installs the plugin with `--ignore-scripts` (as the App Store does) and checks for native addon dependencies **Stray files** — Warns when build and test steps leave untracked files ## Configuration Override defaults by passing inputs to the shared workflow: ```yaml jobs: test: uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master with: test-command: 'npm run test:ci' build-command: 'npm run build:plugin' enable-armv7: false enable-signalk-integration: true node-versions: '["22"]' ``` | Input | Default | Description | | ---------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `test-command` | `npm test` | Command to run your test suite | | `build-command` | `npm run build --if-present` | Build command | | `format-check-command` | _(empty)_ | Blocking format check (e.g. `npm run prettier:check`, `npx biome check .`); skipped when empty | | `coverage-command` | _(empty)_ | Runs tests with coverage (e.g. `npm run coverage`); replaces the standard test run and writes output to the step summary | | `node-versions` | `["22", "24"]` | Node versions for desktop platforms | | `enable-armv7` | `true` | Test on armv7 (Cerbo GX) via QEMU | | `enable-signalk-integration` | `false` | Start SignalK server for integration tests | | `signalk-server-versions` | `["latest"]` | JSON array of signalk-server versions; the integration job fans out over each | ### Formatting and coverage Both are tool-agnostic command strings — the workflow doesn't care whether you use Prettier/Biome or c8/nyc/`jest --coverage`. Leave either empty to opt out. ```yaml with: format-check-command: 'npm run prettier:check' coverage-command: 'npm run coverage' ``` `format-check-command` runs after lint and **blocks the job** if it fails (unlike `npm run lint --if-present`, which is advisory). `coverage-command` replaces the standard `Run tests` step — its stdout is captured and appended to the GitHub Actions step summary so you can see coverage output without digging through logs. ## package.json The CI validates the same fields described in the [publishing guide](./publishing.md). The most important for CI: - `keywords` must include `signalk-node-server-plugin` - `main` or `exports` must point to your entry file - `engines.node` should declare the minimum Node.js version (required if you use `node:sqlite` or other version-specific built-in modules) Plugins without a `test` script still get all validation checks — tests are skipped with a notice. ## armv7 / Cerbo GX Testing The Cerbo GX runs an Allwinner dual-core Cortex-A7 (ARMv7, 32-bit) with Venus OS. The CI emulates this environment using QEMU with a `node:20-bookworm-slim` Docker image plus `python3`, `make`, and `g++` — matching Venus OS 3.70 which ships Node 20 and has build tools available via opkg. The armv7 job runs install, build, and tests — it does not repeat the full validation suite (that's covered by the desktop jobs). The armv7 Node version is fixed to match the Cerbo GX and is not user-configurable. Expect armv7 jobs to take 3-5x longer than native x64. armv7 failures are **advisory and non-blocking**. ### Limitations - **Native addons** compile for armv7 inside the container (slow but works — pre-built binaries rarely exist for ARM32) - **Hardware peripherals** (GPIO, CAN bus, serial) are not emulated — use a self-hosted runner for those ## Integration Tests Enable `enable-signalk-integration: true` to run your plugin against a real Signal K server. The job installs a Signal K server, packs and installs your plugin, auto-enables it, and starts the server with sample NMEA 0183 + NMEA 2000 data so the plugin has a realistic data environment (navigation, wind, depth, temperature, battery, and more). It then verifies the plugin loaded, checks provider API registrations, and runs `npm run test:integration` if defined. Your tests receive `SIGNALK_URL=http://localhost:3000` to connect to the running server. The authoritative sequence of steps lives in the workflow itself: [.github/workflows/plugin-ci.yml](https://github.com/SignalK/signalk-server/blob/master/.github/workflows/plugin-ci.yml). Pass `signalk-server-versions` as a JSON array to fan the integration job out over multiple server versions — useful for catching regressions across the baconjs 1 → 3 transition (server 2.23.x vs 2.24.0+) and similar cross-generation breakage: ```yaml with: enable-signalk-integration: true signalk-server-versions: '["2.23.0", "latest"]' ``` The integration job runs the full Cartesian product of `node-versions × signalk-server-versions`. The default `["22", "24"] × ["latest"]` is 2 jobs; `["22", "24"] × ["2.23.0", "latest"]` is 4. To keep the matrix small, shrink either dimension — integration coverage often only needs a single Node version (`node-versions: '["22"]'`) even when the desktop jobs exercise several. ### Provider API Verification If your plugin registers as a provider for one of the server's provider APIs, the integration test verifies the registration actually works by calling the corresponding endpoint: | Provider API | Registration method | Endpoint checked | | -------------- | ---------------------------------- | ---------------------------------------------------- | | History API v2 | `app.registerHistoryApiProvider()` | `/signalk/v2/api/history/values` must not return 501 | This catches a common class of bugs where a plugin calls a registration method but the endpoint still returns "no provider configured" — for example due to an API mismatch between the plugin and the server version being tested. ## Self-Hosted Runner for Real Hardware For testing against actual hardware (GPIO, CAN bus, serial ports), add a [self-hosted runner](https://docs.github.com/en/actions/hosting-your-own-runners) on a Cerbo GX or Raspberry Pi: ```yaml test-cerbo-hardware: name: Cerbo GX (real hardware) runs-on: [self-hosted, cerbo-gx] steps: - uses: actions/checkout@v6 - run: npm ci - run: npm test ``` ## See also - [Releases and Changelogs](./release.md) — once CI passes, automate the release cut and publish step. ================================================ FILE: docs/develop/plugins/configuration.md ================================================ --- title: Configuration --- # Plugin Configuration A plugin's {@link @signalk/server-api!Plugin.schema | `schema`} function must return a [JSON Schema](http://json-schema.org/) object describing the structure of the configuration data. _Example:_ ```javascript plugin.schema = { type: 'object', required: ['some_string', 'some_other_number'], properties: { some_string: { type: 'string', title: 'Some string that the plugin needs' }, some_number: { type: 'number', title: 'Some number that the plugin needs', default: 60 }, some_other_number: { type: 'number', title: 'Some other number that the plugin needs', default: 5 } } } ``` JSON Schema approach works reasonably well for simple to medium complex configuration data. The server supports also [custom plugin configuration components](../webapps.md), bypassing the automatic configuration format generation. It should be noted that some JSON schema constructs are not supported. Refer to the [RJSF documentation](https://rjsf-team.github.io/react-jsonschema-form/docs/) for details. The configuration data is stored by the server under the following path `$SIGNALK_NODE_CONFIG_DIR/plugin-config-data/.json`. _(Default value of SIGNALK_NODE_CONFIG_DIR is $HOME/.signalk.)_ The plugin is passed the configuration settings as the first parameter of the {@link @signalk/server-api!Plugin.start | `start`} function. ```javascript plugin.start = (settings, restartPlugin) => { // settings contains the plugin configuration ... } ``` ## UI Schema The plugin can define {@link @signalk/server-api!Plugin.uiSchema | `uiSchema`} by returning a [uiSchema object](https://github.com/mozilla-services/react-jsonschema-form#the-uischema-object) which is used to control how the user interface is rendered in the Admin UI. _Example: Make all data in an object called 'myObject' collapsible:_ ```javascript uiSchema['myObject'] = { 'ui:field': 'collapsible', collapse: { field: 'ObjectField', wrapClassName: 'panel-group' } } ``` For more information, see [react-jsonschema-form-extras](https://github.com/RxNT/react-jsonschema-form-extras#collapsible-fields-collapsible) ## Making a plugin enabled by default If your plugin does not require any initial configuration, you can enable it to start when the Signal K server is restarted after the plugin is installed. To do this add the following to the `package.json`: ```json "signalk-plugin-enabled-by-default": true ``` ================================================ FILE: docs/develop/plugins/course_calculations.md ================================================ --- title: Course Providers --- # Course Calculations and Providers The _Course API_ defines the path `/vessels/self/navigation/course/calcValues` to accommodate the calculated values related to course navigation. These paths are available to be populated by a "course provider" plugin that uses the course information set using the _Course API_. This approach promotes the extensibility of Signal K server providing flexibility and interoperability. See [Course Provider Plugins](#course-provider-plugins) below. ## Calculated value paths: `calcValues` The following paths are defined to hold values calculated using the information maintained by _Course API_ operations: - calcMethod: _("Rhumbline" or "GreatCircle")_ - crossTrackError - bearingTrackTrue - bearingTrackMagnetic - estimatedTimeOfArrival _(e.g. "2022-04-22T05:02:56.484Z")_ - distance - bearingTrue - bearingMagnetic - velocityMadeGood - timeToGo - targetSpeed - previousPoint.distance _Example:_ ``` { "calcMethod": "Rhumbline", "crossTrackError": 458.784, "bearingTrackTrue": 4.58491, "bearingTrackMagnetic": 4.51234, "estimatedTimeOfArrival": "2022-04-22T05:02:56.484Z", "distance": 10157, "bearingTrue": 4.58491, "bearingMagnetic": 4.51234, "velocityMadeGood": 7.2653, "timeToGo": 8491, "targetSpeed": 2.2653, "previousPoint": { "distance": 10157 } } ``` ## Course Notifications Calculated course values that cross a threshold should trigger a notification so that the necessary action can be taken. The Course API defines the following notifications which should be implemented by a course provider: - `navigation.course.arrivalCircleEntered` - `navigation.course.perpendicularPassed` ## Course Provider Plugins Signal K server includes the `Course Data Provider` plugin as part of the installation to provide out-of-the-box support for course calculations nd notifications. This plugin can be replaced with others from the AppStore, or your own, to extend the number and types of calculations performed. If you are looking to develop a course provider plugin, following are the recommended guidlines: 1. Ensure values are generated for ALL the defined paths (above) 1. Values MUST be calculated using `/vessels/self/navigation/course` path values mainntained by the _Course API_ 1. Ensure values are set to null when no destination is set or the value cannot be calculated 1. Perform the calculations using a "worker thread" to minimise impact on the server "main thread" 1. Ensure the worker is set up and shut down as part of plugin "start" and "stop" functions 1. Raise the notifications outlined above. ================================================ FILE: docs/develop/plugins/custom_renderers.md ================================================ --- title: Custom Renderers for the Data Browser --- # Custom Renderers Signalk's Data Browser provides an easily navigated snapshot of the state of your Signalk system. Some paths like `navigation.gnss.satellitesInView` however are data intensive and difficult to make much sense out of in raw JSON form. As of Signalk V 2.17.0, you'll notice that the path appears in the Data Browser as an easy to understand graphic: Screenshot 2025-12-15 at 1 56 08 PM This is a Custom Renderer. The code for it is embedded in the DataBrowser package. See: [ValueRenderers.tsx](https://github.com/SignalK/signalk-server/blob/master/packages/server-admin-ui/src/views/DataBrowser/ValueRenderers.tsx). As of Signalk V 2.19.0, there are additional embedded Custom Renderers for Notifications, Attitude, Direction, Meters and Large Arrays. Also as V 2.19.0, developers can create their own renderers and associate them with the path for display in the Data Browser or in any React App. You can add renderers for existing paths, override existing hard-coded renderers, and create novel renderers for any novel paths that your plugin creates. ## Creating a Custom Renderer A Custom Renderer is any React Component that takes `value`, at a minimum, as an argument and renders that value in HTML. Say, for example, you wanted to display a value in bold. You'd create a simple BoldRenderer that would look something like: ``` const BoldRenderer = ({ value }) => { return
value
; } ``` There are more interesting examples in the ValueRenderers.tsx file. ## Making Your Renderer Available at Runtime - Create a plugin - Add your Component in a separate file (usually under [plugin dir]/src/component) - Add build tool includes and scripts to your package.json (Webpack or Vite) - Add keyword "signalk-node-server-addon" to your package.json - Configure Module Federation to export the renderer. Example using Webpack: ```javascript plugins: [ new ModuleFederationPlugin({ name: "Sample renderer", library: { type: "var", name: packageJson.name.replace(/[-@/]/g, "_") }, filename: "remoteEntry.js", exposes: { "./SampleRenderer": "./src/components/SampleRenderer", }, shared: { react: { singleton: true, requiredVersion: false }, "react-dom": { singleton: true, requiredVersion: false } }, }), ... ] ``` **Important:** Configure React as a singleton with `requiredVersion: false` to share the host's React 19 instance. See [vite.config.js](https://github.com/SignalK/signalk-server/blob/master/packages/server-admin-ui/vite.config.js) for the Admin UI's configuration. - Build your plugin (`npm run build`) ## Use Your Renderer To use the renderer, users need to assign the `renderer` property to the path's meta. Example (for a renderer in a federated module): ``` "context": "vessels.self", "updates": [ { "meta": [ { "path": "sample.value", "value": { "renderer": { "module": "renderer-plugin", "name": "SampleRenderer", "options": { "option-1": "optional-value-1" } }, ... ``` ================================================ FILE: docs/develop/plugins/deltas.md ================================================ --- title: Processing Data --- # Processing data from the server A plugin will generally want to: 1. Subscribe to data published by the server _(i.e. received from a NMEA 2000 bus, etc)_ 1. Emit data. In both cases the plugin will use _deltas_ which the server uses to signal changes in the Signal K full data model. Delta messages contain the new value associated with a path (not the amount of change from the previous value.)\_ _See the [Signal K Delta Specification](http://signalk.org/specification/1.7.0/doc/data_model.html#delta-format) for details._ Using the server API, plugins can either: 1. Get the current value of a path in the full model or 1. Subscribe to a path and access a stream of _deltas_ that updates every time the value is updated. By specifying a context _e.g. 'vessels.self'_ you can limit the number of delta messages received to those of host vesseel. To receive all deltas you can specify `*` as the context. You can also limit the deltas received by the path you supply. If you supply a specific path _e.g. navigation.position_, only updates in the value will be received. Since paths are hierarchical, paths can contain wildcards _e.g.\_navigation.\*_ which will deliver deltas containing updates to all paths under `navigation`. The data received is formatted as per the following example: ```javascript { path: 'navigation.position', value: { longitude: 24.7366117, latitude: 59.72493 }, context: 'vessel.self', source: { label: 'n2k-sample-data', type: 'NMEA2000', pgn: 129039, src: '43' }, $source: 'n2k-sample-data.43', timestamp: '2014-08-15T19:00:02.392Z' } ``` ## Reading the current path value The server API provides the following methods for retrieving values from the full data model. - `getSelfPath(path)` returns the value of the supplied `path` in the `vessels.self` context. ```javascript const value = app.getSelfPath('uuid') app.debug(value) // Should output something like urn:mrn:signalk:uuid:a9d2c3b1-611b-4b00-8628-0b89d014ed60 ``` - `getPath(path)` returns the value of the path (including the context) starting from the _root_ of the full data model. ```javascript const baseStations = app.getPath('shore.basestations') ``` ## Subscribing to Deltas A can subscribe to a stream of updates (deltas) by creating the subscription. Subcriptions are generally manged in the plugin `start()` and `stop()` methods to ensure the subscribtions are _unsubscribed_ prior to the plugin stopping to ensure all resources are freed. The following example illustrates the pattern using the {@link @signalk/server-api!ServerAPI.subscriptionmanager | `subscriptionmanager`} API method. ```javascript let unsubscribes = [] plugin.start = (options, restartPlugin) => { app.debug('Plugin started') let localSubscription = { context: '*', // Get data for all contexts subscribe: [ { path: '*', // Get all paths period: 5000 // Every 5000ms } ] } app.subscriptionmanager.subscribe( localSubscription, unsubscribes, (subscriptionError) => { app.error('Error:' + subscriptionError) }, (delta) => { delta.updates.forEach((u) => { app.debug(u) }) } ) } plugin.stop = () => { unsubscribes.forEach((f) => f()) unsubscribes = [] } ``` In the `start()` method create a subscription definition `localSubscription` which is then passed to `app.subscriptionmanager.subscribe()` as the first argument, we also pass the `unsubscribes` array in the second argument. The third argument is a function that will be called when there's an error. The final argument is a function that will be called every time an update is received. In the `stop()` method each subcription in the `unsubscribes` array is _unsubscribed_ and the resources released. ### Path Discovery with `announceNewPaths` When using granular subscriptions (subscribing to specific paths rather than `*`), you may want to discover what paths are available without receiving continuous updates for all of them. The `announceNewPaths` option solves this: ```javascript let localSubscription = { context: '*', announceNewPaths: true, // Announce all matching paths once subscribe: [ { path: 'navigation.position', // Only get continuous updates for this path period: 1000 } ] } ``` When `announceNewPaths: true` is set: 1. **On subscribe**: The server sends cached values for ALL existing paths matching the context filter (once each) 2. **On new path**: When a new path appears later (e.g., a new sensor comes online), the server announces it once 3. **Continuous updates**: Only the explicitly subscribed paths receive continuous updates This is useful for: - **Data browsers** that need to show all available paths but only update visible ones - **Discovery tools** that want to know what data is available - **Dashboards** that let users select which data to display The announced deltas are regular delta messages - there's no special flag. Your client should track which paths it has seen and can then subscribe to specific ones as needed. ## Sending Deltas A SignalK plugin can not only read deltas, but can also send them. This is done using the `handleMessage()` API method and supplying: 1. The plugin id 2. A formatted delta update message 3. The Signal K version ['v1' or 'v2'] _(if omitted the default is 'v1')_. See [REST APIs](../rest-api/README.md) for details. _Example:_ ```javascript app.handleMessage( plugin.id, { updates: [ { values: [ { path: 'environment.outside.temperature', value: -253 } ] } ] }, 'v1' ) ``` ## Sending NMEA 2000 data from a plugin A SignalK plugin can not only emit deltas, but can also send data such as NMEA 2000 data. This is done using the `emit()` API and specifying the provider as well as the formatted data to send. _Example: Send NMEA using Actisense serial format:_ ```javascript app.emit( 'nmea2000out', '2017-04-15T14:57:58.468Z,0,262384,0,0,14,01,0e,00,88,b6,02,00,00,00,00,00,a2,08,00' ) ``` _Example: Send NMEA using Canboat JSON format:_ ```javascript app.emit('nmea2000JsonOut', { pgn: 130306, 'Wind Speed': speed, 'Wind Angle': angle < 0 ? angle + Math.PI * 2 : angle, Reference: 'Apparent' }) ``` ### Sending a message on NMEA2000 startup If you need to send an NMEA2000 message out at startup, _e.g get current state from a device_ you will need to wait until the provider is ready before sending your message. _Example: Send NMEA after the provider is ready:_ ```javascript app.on('nmea2000OutAvailable', () => { app.emit( 'nmea2000out', '2017-04-15T14:57:58.468Z,2,6,126720,%s,%s,4,a3,99,01,00' ) }) ``` ================================================ FILE: docs/develop/plugins/examples/plugin-caller-example.yml ================================================ # ───────────────────────────────────────────────────────────── # SignalK Plugin CI # # Drop this file into your plugin repo at: # .github/workflows/signalk-ci.yml # # It calls the shared SignalK workflow which tests your plugin # across Linux, macOS, and Windows (Node 22 + 24). armv7/Cerbo GX # and the Signal K integration test are opt-in — the auto-run job # below disables them; use the manual trigger to enable per-run. # # On push/PR it runs with sensible defaults. # You can also trigger it manually from the GitHub Actions UI # with custom settings (Node versions, armv7, integration tests). # ───────────────────────────────────────────────────────────── name: SignalK Plugin CI on: push: branches: - '**' pull_request: branches: - '**' workflow_dispatch: inputs: test-command: description: Command to run tests required: false default: npm test type: string build-command: description: Command to build the plugin required: false default: npm run build --if-present type: string format-check-command: description: 'Optional blocking format check, e.g. npm run prettier:check' required: false default: '' type: string coverage-command: description: 'Optional coverage command (replaces the test run), e.g. npm run coverage' required: false default: '' type: string node-versions: description: 'JSON array of Node versions, example: ["22","24"]' required: false default: '["22", "24"]' type: string enable-armv7: description: Run armv7 (Cerbo GX) tests via QEMU required: false default: false type: boolean enable-signalk-integration: description: Start SignalK server and install plugin for integration testing required: false default: false type: boolean signalk-server-versions: description: 'JSON array of signalk-server versions for integration testing, e.g. ["2.23.0","latest"]' required: false default: '["latest"]' type: string jobs: # ── Automatic runs (push / pull request) ────────────────── test-plugin-auto: if: ${{ github.event_name != 'workflow_dispatch' }} uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master with: test-command: npm test build-command: npm run build --if-present node-versions: '["22", "24"]' enable-armv7: false enable-signalk-integration: false signalk-server-versions: '["latest"]' # ── Manual runs (workflow_dispatch with custom inputs) ──── test-plugin-manual: if: ${{ github.event_name == 'workflow_dispatch' }} uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master with: test-command: ${{ inputs.test-command }} build-command: ${{ inputs.build-command }} format-check-command: ${{ inputs.format-check-command }} coverage-command: ${{ inputs.coverage-command }} node-versions: ${{ inputs.node-versions }} enable-armv7: ${{ inputs.enable-armv7 }} enable-signalk-integration: ${{ inputs.enable-signalk-integration }} signalk-server-versions: ${{ inputs.signalk-server-versions }} ================================================ FILE: docs/develop/plugins/examples/plugin-dependabot-example.yml ================================================ # ───────────────────────────────────────────────────────────── # SignalK Plugin Dependabot Config # # Drop this file into your plugin repo at: # .github/dependabot.yml # # Dependabot will then keep your npm dependencies and the # GitHub Actions used by your workflows (CI, release) up to date # by opening PRs on the schedule configured below. # # Non-breaking updates (minor + patch) are grouped into a single # PR per ecosystem to keep noise down. Major version bumps stay # as individual PRs because they usually deserve a closer look. # # Full reference: # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference # ───────────────────────────────────────────────────────────── version: 2 updates: - package-ecosystem: npm directory: '/' schedule: interval: weekly groups: minor-and-patch: update-types: - minor - patch - package-ecosystem: github-actions directory: '/' schedule: interval: weekly groups: actions: update-types: - minor - patch ================================================ FILE: docs/develop/plugins/examples/plugin-release-example.yml ================================================ # ───────────────────────────────────────────────────────────── # SignalK Plugin Release + Publish # # Drop this file into your plugin repo at: # .github/workflows/release.yml # # On every pushed tag (e.g. `1.4.2` or `v1.4.2`) this workflow: # 1. Creates a GitHub Release with auto-generated notes built # from merged PRs since the previous tag. # 2. Publishes the package to npm with provenance. # # Tags containing "beta" (e.g. `1.5.0-beta.1`) are published to # the `beta` npm dist-tag instead of `latest`. # # Prerequisites: # - Create an npm access token and add it as repo secret NPM_TOKEN. # (Needed only if your account requires a token; provenance # works without one via OIDC on public npm packages, but most # accounts still need NPM_TOKEN set.) # - Release the package once manually from your machine so npm # knows the package exists under the correct scope. # # Releasing a new version: # npm version patch # or minor / major — updates package.json and tags # git push && git push --tags # ───────────────────────────────────────────────────────────── name: Release on: push: tags: - '[0-9]+.[0-9]+.[0-9]+*' - 'v[0-9]+.[0-9]+.[0-9]+*' jobs: release: runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} generate_release_notes: true prerelease: ${{ contains(github.ref_name, 'beta') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish: needs: release runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' registry-url: 'https://registry.npmjs.org' - run: npm ci # Assumes your package.json defines `prepublishOnly` (or `prepare`) if a # build step is needed — see the AppStore publishing guide. - name: Publish to npm env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | tag="${GITHUB_REF#refs/tags/}" if [[ "$tag" == *beta* ]]; then npm publish --provenance --access public --tag beta else npm publish --provenance --access public fi ================================================ FILE: docs/develop/plugins/publishing.md ================================================ --- title: Publishing to The AppStore --- # Publishing to The AppStore Plugins and WebApps are available in the AppStore when they have been published to [npm repository](https://www.npmjs.com/) with the one or more of the following keywords in the `package.json` file: - `signalk-node-server-plugin` - `signalk-webapp` Additionally you can have your plugin appear within one or more AppStore categories by also adding the following keyword(s): - `signalk-category-chart-plotters` - `signalk-category-nmea-2000` - `signalk-category-nmea-0183` - `signalk-category-instruments` - `signalk-category-hardware` - `signalk-category-ais` - `signalk-category-notifications` - `signalk-category-digital-switching` - `signalk-category-utility` - `signalk-category-cloud` - `signalk-category-weather` - `signalk-category-deprecated` To have your plugin start automatically after being installed, without requiring any configuration via the **Plugin Config** screen add the following key to the `package.json` file: ```JSON "signalk-plugin-enabled-by-default": true ``` To control the way your WebApp is displayed in the Admin UI add a `signalk` key with the following attributes: ```JSON "signalk": { "appIcon": "./img/icon-72x72.png", // path to an image file to use as an icon. "displayName": "My SK App" // name to display in place of the package name. } ``` _Example: package.json_ ```JSON { "name": "my-signalk-plugin-app", "version": "1.0.0", "description": "My great signalk plugin-app", "keywords": [ "signalk-node-server-plugin", "signalk-webapp", "signalk-category-ais" ], "signalk-plugin-enabled-by-default": true, "signalk": { "appIcon": "./assets/icons/icon-72x72.png", "displayName": "My Great WebApp" }, "main": "plugin/index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC" } ``` ### Important: Avoid install-time scripts The Signal K AppStore installs plugins using `npm install --ignore-scripts` for security reasons. This means any `preinstall`, `install`, or `postinstall` scripts in your plugin's `package.json` will not run when users install your plugin through the AppStore. Ensure your npm package is ready to use without requiring install-time scripts. If you need build steps, use `prepublishOnly` instead - this runs before publishing to npm, so your package already contains everything it needs. #### Publishing your Plugin Once you have developed and tested your Plugin / WebApp you can publish it to make it visible in the AppStore. To do this, in a terminal session from within the folder containing `package.json`: ```shell npm publish ``` For automated releases with GitHub-generated changelogs and npm provenance, see [Releases and Changelogs](./release.md). ================================================ FILE: docs/develop/plugins/release.md ================================================ --- title: Releases and Changelogs --- # Releases and Changelogs for Plugins When a user updates a plugin through the AppStore, they currently see only the new version number — nothing about what changed. This page describes a light-touch convention that makes per-release notes available to tools (a future AppStore, Dependabot, npm's package page) and to humans browsing your repository. The recommendation is deliberately tool-agnostic. Pick whichever of the established approaches below fits your workflow; what matters is the **output shape**, not how you produce it. ## The contract For each npm-published version of your plugin, aim to have: 1. A **GitHub Release** with a tag that matches the npm version (e.g. npm `1.4.2` ↔ git tag `v1.4.2` or `1.4.2`). 2. A human-readable **release body** in Markdown — a few lines is plenty. 3. _Optional:_ a `CHANGELOG.md` at the repo root following [Keep a Changelog](https://keepachangelog.com/). Anything that produces that shape is fine. Downstream consumers read GitHub Releases via the public API, which is the same regardless of which tool produced the notes. ## Approach 1 — GitHub's built-in auto-generated notes (zero config) The simplest path: let GitHub generate the release body from merged PRs. This is the same logic as the **Generate release notes** button in the GitHub web UI, triggered from a workflow on tag push. No third-party changelog action required. The [example workflow](./examples/plugin-release-example.yml) uses this approach — tag push → create GitHub Release with auto-generated body → `npm publish --provenance`. If you want to categorize entries by PR label (Features / Fixes / Other), add a [`.github/release.yml`](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuring-automatically-generated-release-notes) to your repo. GitHub reads it automatically. ## Approach 2 — release-drafter [release-drafter](https://github.com/release-drafter/release-drafter) accumulates a draft release across PR merges, so you can review and edit notes before tagging. Useful if you want a human pass over the wording. ## Approach 3 — release-please / semantic-release / changesets [release-please](https://github.com/googleapis/release-please), [semantic-release](https://semantic-release.gitbook.io/), and [changesets](https://github.com/changesets/changesets) go further: they also drive **version bumps** from conventional commits or intent files, and maintain `CHANGELOG.md` automatically. More automation, more opinion — worth it for plugins that release frequently. ## Commit hygiene Whichever approach you pick, the quality of the generated notes depends on your commit messages and PR titles. The [Signal K server contributing guide](https://github.com/SignalK/signalk-server/blob/master/CONTRIBUTING.md) already covers this — the same conventions apply to plugin repositories. When using GitHub's built-in generator (Approach 1), **PR titles become the release-note lines**. Write titles that make sense out of context: "fix AIS fallback when GPS source is missing" beats "fix bug". ## npm publish and provenance Publish from a GitHub Actions job triggered by the same tag push that creates the release. Use [npm provenance](https://docs.npmjs.com/generating-provenance-statements) so consumers can verify the package was built from the linked commit: ```yaml permissions: id-token: write # required for provenance contents: read steps: - run: npm publish --provenance --access public ``` The [example workflow](./examples/plugin-release-example.yml) shows the full shape, including a beta-tag branch for `*-beta*` versions. If the publish step fails transiently after the GitHub Release was already created, re-run the failed `publish` job from the Actions UI. If that's not possible, cut a new patch tag (e.g. `v1.4.3`) rather than force-retagging — tags and releases should be immutable so downstream consumers of the GitHub Release can trust them. ## Dependabot Dependabot fetches the GitHub Release notes of each updated package and embeds them into its update PRs — so plugins that follow the contract above already benefit their downstream users. Dependabot is also useful _for_ your plugin: it keeps the dependencies of your plugin (and the versions of the GitHub Actions your CI/release workflows use) up to date. A minimal `.github/dependabot.yml` should cover two ecosystems: - `npm` — your plugin's runtime dependencies - `github-actions` — the versions of the actions your CI and release workflows use Group non-breaking upgrades (minor + patch) into a single PR per ecosystem to cut down on noise; leave major-version bumps as their own PRs because they usually need a real look. See [`examples/plugin-dependabot-example.yml`](./examples/plugin-dependabot-example.yml) for a copy-pasteable config with explanatory comments, and the [Dependabot options reference](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference) for the full set of tunables. ## See also - [Continuous Integration for Plugins](./ci.md) — validate your plugin before releasing - [Publishing to The AppStore](./publishing.md) — npm keywords and `package.json` shape - [Keep a Changelog](https://keepachangelog.com/) - [Semantic Versioning](https://semver.org/) - [GitHub: Automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) - [npm: Generating provenance statements](https://docs.npmjs.com/generating-provenance-statements) ================================================ FILE: docs/develop/plugins/resource_provider_plugins.md ================================================ --- title: Resource Providers --- # Resource Provider plugins The Signal K server _Resource API_ provides a common set operations for clients to interact with routes, waypoints, charts, etc but it does NOT provide the ability to persist or retrieve resources to / from storage. This functionality needs to be provided by one or more server plugins that interface with the _Resource API_ to facilitate the storage and retrieval of resource data. These plugins are called **Provider Plugins**. _Resource API architecture:_ This de-coupling of request handling and data storage provides the flexibility to persist resource data in a variety of different storage types as well as Internet based services. > [!NOTE] > Signal K server comes with the [resources-provider-plugin](https://github.com/SignalK/signalk-server/tree/master/packages/resources-provider-plugin) pre-installed which persists resource data to the local file system. ## Resources API The _[Resources API](../rest-api/resources_api.md)_ handles all client requests received via the `/signalk/v2/api/resources` path, before passing on the request to registered provider plugin(s). The _Resources API_ performs the following operations when a request is received: 1. Checks for registered provider(s) for the resource type _(i.e. route, waypoint, etc.)_ 1. Checks that the required ResourceProvider methods are defined for the requested operation _(i.e. POST, PUT, GET, DELETE)_ 1. Performs an access control check 1. `POST` and `PUT` requests for **Standard** _(Signal K defined)_ resource types are checked for validity of the submitted: - `resource id` - `resource data` against the OpenAPI definition. Only after successful completion of all these operations is the request passed on to the registered provider plugin(s). --- ## Provider plugins A resource provider plugin is a Signal K server plugin that implements the {@link @signalk/server-api!ResourceProvider | ResourceProvider } interface which: - Tells server the resource type(s) provided for by the plugin _(i.e. route, waypoint, etc.)_ - Registers the methods used to action requests passed from the server and perform the writing, retrieval and deletion of resources from storage. _Note: The plugin **MUST** implement each method, even if that operation is NOT supported by the plugin!_ > [!NOTE] > Multiple providers can be registered for a resource type _(e.g. 2 x chart providers)_ _**Note: The Resource Provider is responsible for implementing the methods and returning data in the required format!**_ ## Registering as a Resource Provider To register a plugin as a provider for one or more resource types with the SignalK server, it must call the server's {@link @signalk/server-api!ResourceProviderRegistry.registerResourceProvider | `registerResourceProvider`} function for each resource type being serviced during plugin startup. _Example: Plugin registering as a routes & waypoints provider._ ```javascript import { ResourceProvider } from '@signalk/server-api' module.exports = function (app) { const plugin = { id: 'mypluginid', name: 'My Resource Provider plugin' } const routesProvider: ResourceProvider = { type: 'routes', methods: { listResources: (params) => { fetchRoutes(params) ... }, getResource: (id, property?) => { getRoute(id, property) ... }, setResource: (id, value )=> { saveRoute(id, value) ... }, deleteResource: (id) => { deleteRoute(id, value) ... } } } const waypointsProvider: ResourceProvider = { type: 'waypoints', methods: { listResources: (params) => { fetchWaypoints(params) ... }, getResource: (id, property?) => { getWaypoint(id, property) ... }, setResource: (id, value )=> { saveWaypoint(id, value) ... }, deleteResource: (id) => { deleteWaypoint(id, value) ... } } } plugin.start = function(options) { ... try { app.registerResourceProvider(routesProvider) app.registerResourceProvider(waypointsProvider) } catch (error) { // handle error } } return plugin } ``` ## Resource Provider Methods A Resource Provider plugin must implement ALL methods in {@link @signalk/server-api!ResourceProviderMethods | `ResourceProviderMethods`} to service the requests passed from the server. Each method should return a **Promise** on success and `throw` on error, if a request is not serviced or is not implemented. _Example:_ ```javascript // SignalK server plugin module.exports = function (app) { const plugin = { id: 'mypluginid', name: 'My Resource Providerplugin', start: options => { ... app.registerResourceProvider({ type: 'waypoints', methods: { listResources: (params) => { return new Promise( (resolve, reject) => { ... if (ok) { resolve(resource_list) } else { reject( new Error('Error fetching resources!')) } }) }, getResource: (id, property?) => { return new Promise( (resolve, reject) => { ... if (ok) { resolve(resource_list) } else { reject( new Error('Error fetching resource with supplied id!')) } }) }, setResource: (id, value )=> { throw( new Error('Not implemented!')) }, deleteResource: (id) => { throw( new Error('Not implemented!')) } } }) } } } ``` ## Delta Notifications for Internal Resource Changes While the built-in Resources API automatically emits deltas for standard operations (`POST`, `PUT`, `DELETE`), custom provider endpoints must manually emit deltas when resources are modified through custom endpoints to keep clients synchronized in real-time. Emit delta notifications after: 1. **Create** - New resource added (via upload, file copy, download, etc.) 2. **Update** - Resource modified (rename, move, enable/disable, etc.) 3. **Delete** - Resource removed ### Delta Message Format Resource deltas use the standard Signal K delta format with the resource path. **Target version 2 data structure**. ```javascript app.handleMessage( 'my-provider-plugin-id', { updates: [ { values: [ { path: 'resources..', value: resourceData // or null for deletions } ] } ] }, 2 // Signal K v2 - resources should not be in full model cache ) ``` ### Example: Complete Implementation This example shows a chart provider plugin that emits deltas for all operations: ```javascript module.exports = function (app) { let chartCache = {} const plugin = { id: 'my-charts-provider', name: 'My Charts Provider', start: (options) => { // Register as resource provider app.registerResourceProvider({ type: 'charts', methods: { listResources: () => Promise.resolve(chartCache), getResource: (id) => { if (chartCache[id]) { return Promise.resolve(chartCache[id]) } throw new Error('Chart not found') }, setResource: (id, value) => { throw new Error('Not implemented') }, deleteResource: (id) => { throw new Error('Not implemented') } } }) // Register custom endpoints registerCustomEndpoints() // Initial load refreshCharts() }, registerWithRouter: (router) => { router.post('/charts/upload', async (req, res) => { try { const chartId = await saveUploadedChart(req) await refreshCharts() if (chartCache[chartId]) { emitChartDelta(chartId, chartCache[chartId]) } res.json({ success: true, id: chartId }) } catch (error) { res.status(500).json({ error: error.message }) } }) // Delete endpoint router.delete('/charts/:id', async (req, res) => { try { await deleteChartFromDisk(req.params.id) await refreshCharts() emitChartDelta(req.params.id, null) res.send('Chart deleted successfully') } catch (error) { res.status(500).send(error.message) } }) } } const emitChartDelta = (chartId, chartValue) => { try { app.handleMessage( plugin.id, { updates: [ { values: [ { path: `resources.charts.${chartId}`, value: chartValue } ] } ] }, 2 // Signal K v2 - resources should not be in full model cache ) app.debug(`Delta emitted for chart: ${chartId}`) } catch (error) { app.error(`Failed to emit delta: ${error.message}`) } } const refreshCharts = async () => { try { const charts = await loadChartsFromDisk() chartCache = charts app.debug(`Charts refreshed: ${Object.keys(chartCache).length} charts`) } catch (error) { app.error(`Failed to refresh charts: ${error.message}`) } } return plugin } ``` ### Client Subscription Clients can subscribe to resource changes via WebSocket: ```javascript { "context": "resources.*", "subscribe": [ { "path": "charts.*", "policy": "instant" } ] } ``` When resources change, clients receive delta messages: ```json { "context": "resources", "updates": [{ "values": [{ "path": "charts.myChart", "value": { "name": "My Chart", "description": "Chart description", ... } }] }] } ``` For deletions, `value` is `null`: ```json { "context": "resources", "updates": [ { "values": [ { "path": "charts.myChart", "value": null } ] } ] } ``` ### Reference Implementation The [signalk-charts-provider-simple](https://github.com/dirkwa/signalk-charts-provider-simple) plugin provides a complete working example of this pattern. ================================================ FILE: docs/develop/plugins/wasm/README.md ================================================ --- title: WASM Plugins children: - assemblyscript.md - rust.md - go.md - http_endpoints.md - deltas.md - capabilities.md - best_practices.md - integration_guide.md --- # WASM Plugin Development Guide ## Overview This guide covers how to develop WASM/WASIX plugins for Signal K Server 3.0. WASM plugins run in a secure sandbox with isolated storage and capability-based permissions. ## What Makes a WASM Plugin? A WASM plugin is an npm package that contains the WASM code for the plugin instead of the traditional JavaScript code. A WASM plugin is identified by the `signalk-wasm-plugin` keyword in package.json and the **`wasmManifest`** field in `package.json`: ```json { "name": "my-plugin-name", "wasmManifest": "plugin.wasm", "wasmCapabilities": { ... } } ``` **Key points:** - **`wasmManifest`** (required): Path to the compiled `.wasm` file. This field tells Signal K to load this as a WASM plugin instead of a Node.js plugin. - **`wasmCapabilities`** (required): Declares what permissions the plugin needs (network, storage, etc.) - **Package name** (flexible): Can be anything - `my-plugin`, `@myorg/my-plugin`, etc. There is **no requirement** to use `@signalk/` scope. - **Keywords**: Include `signalk-wasm-plugin` for discovery (do **not** use `signalk-node-server-plugin` - that's for Node.js plugins only) ## Language Options Signal K Server supports multiple languages for WASM plugin development: - **AssemblyScript** - TypeScript-like syntax, easiest for JS/TS developers, smallest binaries (3-10 KB) - **Rust** - Best performance and tooling, medium binaries (50-200 KB) - **Go/TinyGo** - Go via TinyGo compiler, medium binaries (50-150 KB) ## Why WASM Plugins? ### Benefits - **Security**: Sandboxed execution with no access to host system - **Hot-reload**: Update plugins without server restart - **Multi-language**: Write plugins in Rust, AssemblyScript, and more - **Crash isolation**: Plugin crashes don't affect server - **Performance**: Near-native performance with WASM - **Self contained**: WASM plugins do not install any additional dependencies - **Small binaries (compared to native options)**: 3-200 KB depending on language ### Current Capabilities - **Delta Emission**: Send SignalK deltas to update vessel data - **Status & Error Reporting**: Set plugin status and error messages - **Configuration**: The same JSON schema-based configuration as JS plugins - **Data Storage**: VFS-isolated file storage - **HTTP Endpoints**: Register custom REST API endpoints - **Static Files**: Serve web UI from `public/` directory - **Network Access**: HTTP requests via as-fetch (AssemblyScript) - **Resource Providers**: Serve SignalK resources - **Weather Providers**: Integrate with Signal K Weather API - **Radar Providers**: Integrate with Signal K Radar API ## Choose Your Language ### AssemblyScript - Recommended for JS/TS Developers **Best for:** - Quick prototypes - Simple data processing - Migrating existing Node.js plugins - Developers familiar with TypeScript **Pros:** - TypeScript-like syntax - Fast development - Smallest binaries (3-10 KB) - Familiar tooling (npm) **Cons:** - Smaller ecosystem than Rust - Some TypeScript features unavailable - Manual memory management **[Jump to AssemblyScript Guide](./assemblyscript.md)** ### Rust - Recommended for Performance-Critical Plugins **Best for:** - Performance-critical plugins - Complex algorithms - Low-level operations - Production plugins **Pros:** - Best performance - Memory safety - Rich ecosystem - Strong typing **Cons:** - Steeper learning curve - Longer compile times - Larger binaries (50-200 KB) **[Jump to Rust Guide](./rust.md)** ### Go/TinyGo - For Go Developers **Best for:** - Go developers wanting to write plugins - Medium complexity plugins - Resource providers with hybrid patterns **Pros:** - Familiar Go syntax - Good standard library support - Medium binaries (50-150 KB) - Strong typing **Cons:** - Requires TinyGo (not standard Go) - Some Go features unavailable - Slower than Rust **[Jump to Go/TinyGo Guide](./go.md)** ================================================ FILE: docs/develop/plugins/wasm/assemblyscript.md ================================================ --- title: AssemblyScript Plugins --- # Creating AssemblyScript Plugins AssemblyScript is the recommended language for developers familiar with TypeScript. It produces the smallest binaries (3-10 KB) and has the fastest development cycle. ## Step 1: Install SDK ```bash npm install @signalk/assemblyscript-plugin-sdk npm install --save-dev assemblyscript ``` ## Step 2: Create Plugin File Create `assembly/index.ts`: ```typescript import { Plugin, Delta, Update, PathValue, emit, setStatus } from '@signalk/assemblyscript-plugin-sdk/assembly' class MyPlugin extends Plugin { name(): string { return 'My AssemblyScript Plugin' } schema(): string { return `{ "type": "object", "properties": { "updateRate": { "type": "number", "default": 1000 } } }` } start(config: string): i32 { setStatus('Started') // Emit a test delta const pathValue = new PathValue('test.value', '"hello"') const update = new Update([pathValue]) const delta = new Delta('vessels.self', [update]) emit(delta) return 0 // Success } stop(): i32 { setStatus('Stopped') return 0 } } // Export for Signal K const plugin = new MyPlugin() export function plugin_name(): string { return plugin.name() } export function plugin_schema(): string { return plugin.schema() } export function plugin_start(configPtr: usize, configLen: usize): i32 { const configBytes = new Uint8Array(configLen) for (let i = 0; i < configLen; i++) { configBytes[i] = load(configPtr + i) } const configJson = String.UTF8.decode(configBytes.buffer) return plugin.start(configJson) } export function plugin_stop(): i32 { return plugin.stop() } ``` **Note on Plugin IDs:** The plugin ID is automatically derived from your `package.json` name. For example: - `@signalk/example-weather-plugin` → `_signalk_example-weather-plugin` - `my-simple-plugin` → `my-simple-plugin` This ensures unique plugin IDs (npm guarantees package name uniqueness) and eliminates discrepancies between package name and plugin ID. ## Step 3: Configure Build Create `asconfig.json`: ```json { "targets": { "release": { "outFile": "plugin.wasm", "optimize": true, "shrinkLevel": 2, "converge": true, "noAssert": true, "runtime": "incremental", "exportRuntime": true }, "debug": { "outFile": "build/plugin.debug.wasm", "sourceMap": true, "debug": true, "runtime": "incremental", "exportRuntime": true } }, "options": { "bindings": "esm" } } ``` **Important**: `exportRuntime: true` is **required** for the AssemblyScript loader to work. This exports runtime helper functions like `__newString` and `__getString` that the server uses for automatic string conversions. ## Step 4: Build ```bash npx asc assembly/index.ts --target release ``` ## Step 5: Create package.json ```json { "name": "my-wasm-plugin", "version": "0.1.0", "keywords": ["signalk-wasm-plugin"], "wasmManifest": "plugin.wasm", "wasmCapabilities": { "dataRead": true, "dataWrite": true, "storage": "vfs-only" } } ``` > **Important: What makes a WASM plugin?** > > The **`wasmManifest`** field is the key identifier that tells Signal K this is a WASM plugin (not a Node.js plugin). It must point to your compiled `.wasm` file. > > The package **name can be anything** - scoped (`@myorg/my-plugin`) or unscoped (`my-wasm-plugin`). Choose a name that makes sense for your plugin and avoids conflicts on npm. ## Step 6: Test Install **Option 1: Symlink (Recommended for Development)** Symlinking your plugin directory allows you to make changes and rebuild without copying files: ```bash # From your Signal K node_modules directory cd ~/.signalk/node_modules ln -s /path/to/your/my-wasm-plugin my-wasm-plugin # Now any changes you make and rebuild will be picked up on server restart ``` **Option 2: Direct Copy** ```bash mkdir -p ~/.signalk/node_modules/my-wasm-plugin cp plugin.wasm package.json ~/.signalk/node_modules/my-wasm-plugin/ # If your plugin has a public/ folder with web UI: cp -r public ~/.signalk/node_modules/my-wasm-plugin/ ``` **Option 3: NPM Package Install** ```bash # If you've packaged with `npm pack` npm install -g ./my-wasm-plugin-1.0.0.tgz # Or install from npm registry (if published) npm install -g my-wasm-plugin ``` **Note**: Symlinking is the most efficient method for development - changes are picked up on server restart without copying files. Use npm install for production deployments or when distributing plugins. **Important**: If your plugin includes static files (like a web UI in the `public/` folder), make sure to copy that folder as well. Static files are automatically served at `/plugins/your-plugin-id/` when the plugin is loaded. ## Step 7: Verify Plugin Configuration in Admin UI After installing your plugin, verify it appears in the Admin UI: 1. **Navigate to Plugin Configuration**: Open the Admin UI at `http://your-server:3000/admin/` and go to **Server → Plugin Config** 2. **Check Plugin List**: Your WASM plugin should appear in the list with: - Plugin name (from `name()` export) - Version (from `package.json`) - Enable/Disable toggle - Configuration form (based on `schema()` export) 3. **Verify Configuration Persistence**: - Configuration is saved to `~/.signalk/plugin-config-data/your-plugin-id.json` - Changes are applied immediately (plugin restarts automatically) - The file structure is: ```json { "enabled": true, "enableDebug": false, "configuration": { "updateRate": 1000 } } ``` 4. **Troubleshooting**: - If plugin doesn't appear: Check `package.json` has the `signalk-wasm-plugin` keyword and `wasmManifest` field - If configuration form is empty: Verify `schema()` export returns valid JSON Schema - If settings don't persist: Check file permissions on `~/.signalk/plugin-config-data/` **Important**: The Admin UI shows all plugins (both Node.js and WASM) in a unified list. WASM plugins integrate seamlessly with the existing plugin configuration system. ## API Reference ### Base Classes #### `Plugin` Abstract base class for all plugins. **Methods to implement:** - `id(): string` - Unique plugin identifier - `name(): string` - Human-readable name - `schema(): string` - JSON schema for configuration - `start(config: string): i32` - Initialize plugin - `stop(): i32` - Clean shutdown ### Signal K Types #### `Delta` Represents a Signal K delta message. ```typescript const delta = new Delta('vessels.self', [update]) ``` #### `Update` Represents an update within a delta. The server automatically adds `$source` and `timestamp`. ```typescript const update = new Update([pathValue]) ``` #### `PathValue` Represents a path-value pair. ```typescript const pathValue = new PathValue('navigation.position', positionJson) ``` #### `Position` GPS position with latitude/longitude. ```typescript const pos = new Position(60.1, 24.9) const posJson = pos.toJSON() ``` #### `Notification` Signal K notification. ```typescript const notif = new Notification(NotificationState.normal, 'Hello!') const notifJson = notif.toJSON() ``` ### API Functions #### `emit(delta: Delta): void` Emit a delta message to Signal K server. ```typescript emit(delta) ``` **Requires capability:** `dataWrite: true` #### `setStatus(message: string): void` Set plugin status (shown in admin UI). ```typescript setStatus('Running normally') ``` #### `setError(message: string): void` Report an error (shown in admin UI). ```typescript setError('Sensor connection failed') ``` #### `debug(message: string): void` Log debug message to server logs. ```typescript debug('Processing data: ' + value.toString()) ``` #### `getSelfPath(path: string): string | null` Read data from vessel.self. ```typescript const speedJson = getSelfPath('navigation.speedOverGround') if (speedJson !== null) { const speed = parseFloat(speedJson) } ``` **Requires capability:** `dataRead: true` #### `getPath(path: string): string | null` Read data from any context. ```typescript const posJson = getPath('vessels.self.navigation.position') ``` **Requires capability:** `dataRead: true` #### `readConfig(): string` Read plugin configuration. ```typescript const configJson = readConfig() ``` #### `saveConfig(configJson: string): i32` Save plugin configuration. ```typescript const result = saveConfig(JSON.stringify(config)) if (result !== 0) { setError('Failed to save config') } ``` ### Helper Functions ```typescript import { createSimpleDelta, getCurrentTimestamp } from '@signalk/assemblyscript-plugin-sdk' // Quick delta creation const delta = createSimpleDelta('my-plugin', 'test.value', '"hello"') emit(delta) ``` ### JSON Parsing The SDK includes [assemblyscript-json](https://github.com/near/assemblyscript-json) for parsing JSON data. This is useful when working with configuration, API responses, or resource provider requests. ```typescript import { JSON } from '@signalk/assemblyscript-plugin-sdk/assembly' // Parse a JSON string const jsonStr = '{"name": "My Boat", "speed": 5.2}' const parsed = JSON.parse(jsonStr) if (parsed.isObj) { const obj = parsed as JSON.Obj // Get string values const nameValue = obj.getString('name') if (nameValue !== null) { const name = nameValue.valueOf() // "My Boat" } // Get number values const speedValue = obj.getNum('speed') if (speedValue !== null) { const speed = speedValue.valueOf() // 5.2 (as f64) } } ``` **Available methods on `JSON.Obj`:** - `getString(key)` - Returns `JSON.Str | null` - `getNum(key)` - Returns `JSON.Num | null` - `getBool(key)` - Returns `JSON.Bool | null` - `getObj(key)` - Returns `JSON.Obj | null` - `getArr(key)` - Returns `JSON.Arr | null` - `getValue(key)` - Returns `JSON.Value | null` **Note:** Plugins using resource providers or parsing complex JSON should add `assemblyscript-json` to their dependencies: ```bash npm install assemblyscript-json ``` ### JSON Value Encoding Values must be JSON-encoded strings: ```typescript // Numbers const pathValue = new PathValue('temperature', '25.5') // Strings (note the quotes) const pathValue = new PathValue('name', '"My Boat"') // Objects const pathValue = new PathValue( 'position', '{"latitude":60.1,"longitude":24.9}' ) // Use helper classes const pos = new Position(60.1, 24.9) const pathValue = new PathValue('position', pos.toJSON()) ``` ## Resource Providers WASM plugins can register as **resource providers** to serve data via the Signal K REST API. ### Setup 1. Add capability to `package.json`: ```json { "wasmCapabilities": { "resourceProvider": true } } ``` 2. Register in your plugin's `start()`: ```typescript import { registerResourceProvider, ResourceGetRequest } from '@signalk/assemblyscript-plugin-sdk/assembly/resources' start(config: string): i32 { if (registerResourceProvider('weather')) { debug('Registered as weather resource provider') } return 0 } ``` 3. Export handler functions: ```typescript // List all resources - GET /signalk/v2/api/resources/weather export function resources_list_resources(queryJson: string): string { return '{"current":' + cachedData.toJSON() + '}' } // Get specific resource - GET /signalk/v2/api/resources/weather/{id} export function resources_get_resource(requestJson: string): string { const req = ResourceGetRequest.parse(requestJson) if (req.id === 'current') { return cachedData.toJSON() } return '{"error":"Not found"}' } ``` ### API Access Once registered, your resources are available at: ```bash curl http://localhost:3000/signalk/v2/api/resources/weather curl http://localhost:3000/signalk/v2/api/resources/weather/current ``` ## Network Requests with Asyncify AssemblyScript plugins can make HTTP requests using the `as-fetch` library with Asyncify support. ### Setup 1. Add dependencies: ```bash npm install as-fetch @signalk/assemblyscript-plugin-sdk ``` 2. Enable the Asyncify transform in `asconfig.json`: ```json { "options": { "bindings": "esm", "exportRuntime": true, "transform": ["as-fetch/transform"] } } ``` 3. Declare network capability in `package.json`: ```json { "wasmCapabilities": { "network": true } } ``` ### Making Requests ```typescript import { fetchSync } from 'as-fetch/sync' const response = fetchSync('https://api.example.com/data') if (response && response.status === 200) { const data = response.text() // Process data... } ``` ### How Asyncify Works Asyncify enables synchronous-style async code in WASM: 1. WASM execution pauses when `fetchSync()` is called 2. HTTP request happens in JavaScript 3. When response arrives, WASM execution resumes 4. Your code continues with the response The Signal K runtime handles all state transitions automatically. ### Troubleshooting Network Requests **fetchSync hangs or doesn't work:** - Ensure `"transform": ["as-fetch/transform"]` is in `asconfig.json` - Use correct import: `import { fetchSync } from 'as-fetch/sync'` - Verify `"network": true` in `wasmCapabilities` **Request fails:** - Check Node.js version >= 18 (required for native fetch) - Verify the URL is accessible - Check API keys/authentication See the [example-weather-plugin](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-weather-plugin) for a complete implementation. ## AssemblyScript Limitations AssemblyScript is a **strict subset** of TypeScript. Notable differences: - No `any` type - No union types (use tagged enums) - No dynamic arrays (use fixed-size or manual memory) - No standard library (console, setTimeout, etc.) - Manual memory management See [AssemblyScript documentation](https://www.assemblyscript.org/) for details. ## Troubleshooting ### Plugin doesn't load Check that: - `wasmManifest` points to correct file - `signalk-wasm-plugin` keyword is present - WASM binary is valid: `file plugin.wasm` ### Compilation errors Common issues: - Using disallowed TypeScript features - Missing type annotations - Incorrect memory operations ### Runtime errors Check server logs: ```bash DEBUG=signalk:wasm:* npm start ``` ## Additional Resources - [AssemblyScript Documentation](https://www.assemblyscript.org/) - [Example Plugins](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins) ================================================ FILE: docs/develop/plugins/wasm/best_practices.md ================================================ --- title: Best Practices for WASM Plugins --- # Best Practices for WASM Plugins ## Hot Reload WASM plugins support hot-reload without server restart: ### Manual Reload 1. Build new WASM binary: `cargo build --target wasm32-wasip1 --release` 2. Copy to plugin directory: `cp target/wasm32-wasip1/release/*.wasm ~/.signalk/...` 3. In Admin UI: **Server** → **Plugin Config** → Click **Reload** button ### Reload Behavior During reload: - `stop()` is called on old instance - Subscriptions are preserved - Deltas are buffered (not lost) - New instance is loaded - `start()` is called with saved config - Buffered deltas are replayed ## Error Handling ### Crash Recovery If a WASM plugin crashes: 1. **First crash**: Automatic restart after 1 second 2. **Second crash**: Restart after 2 seconds 3. **Third crash**: Restart after 4 seconds 4. **After 3 crashes**: Plugin disabled, admin notification ### Error Reporting Report errors to admin UI: ```rust fn handle_error(err: &str) { sk_set_error(&format!("Error: {}", err)); } ``` ## Optimization ### 1. Minimize Binary Size ```toml [profile.release] opt-level = "z" # Optimize for size lto = true # Enable link-time optimization strip = true # Strip debug symbols ``` Use `wasm-opt` for further optimization: ```bash wasm-opt -Oz plugin.wasm -o plugin.wasm ``` ### 2. Handle Errors Gracefully ```rust fn start(config_ptr: *const u8, config_len: usize) -> i32 { match initialize_plugin(config_ptr, config_len) { Ok(_) => { sk_set_status("Started"); 0 // Success } Err(e) => { sk_set_error(&format!("Failed to start: {}", e)); 1 // Error } } } ``` ### 3. Use Efficient JSON Parsing ```rust use serde::{Deserialize, Serialize}; #[derive(Deserialize)] struct Config { #[serde(default)] enabled: bool, } fn parse_config(json: &str) -> Result { serde_json::from_str(json) } ``` ### 4. Limit Memory Usage - Avoid large allocations - Clear buffers after use - Use streaming for large data ### WASM Memory Limitations WASM plugins running in Node.js have **~64KB buffer limitations** for stdin/stdout operations. This is a fundamental limitation of the Node.js WASI implementation, not a Signal K restriction. **Impact:** - Small JSON responses (< 64KB): Work fine in pure WASM - Medium data (64KB - 1MB): May freeze or fail - Large data (> 1MB): Will fail or freeze the server **Hybrid Architecture Pattern** For plugins that need to handle large data volumes (logs, file streaming, large JSON responses), use a **hybrid approach**: - **WASM Plugin**: Registers HTTP endpoints and provides configuration UI - **Node.js Handler**: Server intercepts specific endpoints and handles I/O directly in Node.js - **Result**: Can handle unlimited data without memory constraints Use this pattern when your plugin needs to: - Return large JSON responses (> 64KB) - Process large file uploads - Handle streaming data ### 5. Provide Good UX - Clear status messages - Descriptive error messages - Comprehensive JSON schema for configuration ## Debugging ### Logging ```rust fn debug_log(message: &str) { unsafe { sk_debug(message.as_ptr(), message.len()); } } ``` ### Testing Locally 1. Build with debug symbols: `cargo build --target wasm32-wasip1` 2. Use `wasmtime` for local testing: ```bash wasmtime --dir /tmp::/ plugin.wasm ``` ### Enable Server Debug Logging ```bash # Linux/macOS DEBUG=signalk:wasm:* signalk-server ``` ### Common Issues **Issue**: Plugin doesn't load **Solution**: Check `wasmManifest` path in package.json **Issue**: Capability errors **Solution**: Ensure required capabilities declared in package.json **Issue**: Crashes on start **Solution**: Check server logs for error details ## Migration from Node.js ### 1. Assess Compatibility Check if your plugin: - ✅ Processes deltas - ✅ Reads/writes configuration - ✅ Uses data model APIs - ✅ Registers REST endpoints - ❌ Uses serial ports (planned but not there yet) - ✅ Makes HTTP requests (via as-fetch in AssemblyScript) - ✅ Uses UDP/TCP sockets (rawSockets capability) ### 2. Port Logic to Rust Convert TypeScript/JavaScript logic to Rust: **Before (Node.js):** ```javascript plugin.start = function (config) { app.handleMessage('my-plugin', { updates: [{ values: [{ path: 'foo', value: 'bar' }] }] }) } ``` **After (WASM/Rust):** ```rust fn start(config_ptr: *const u8, config_len: usize) -> i32 { let delta = json!({ "updates": [{ "values": [{ "path": "foo", "value": "bar" }] }] }); sk_emit_delta(&delta.to_string()); 0 } ``` ### 3. Migrate Data Use migration helper to copy existing data to VFS: ```rust fn first_run_migration() { // Server provides migration API // Copies files from ~/.signalk/plugin-config-data/{id}/ // to ~/.signalk/plugin-config-data/{id}/vfs/data/ } ``` ## Example Plugins The following example plugins are available in the repository: - [example-hello-assemblyscript](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-hello-assemblyscript) - Minimal AssemblyScript plugin that emits a delta on start - [example-anchor-watch-rust](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-anchor-watch-rust) - Anchor watch plugin in Rust - [example-routes-waypoints](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-routes-waypoints) - Resource provider for routes and waypoints - [example-weather-provider](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-weather-provider) - Weather API provider implementation - [example-weather-plugin](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-weather-plugin) - Weather data plugin ================================================ FILE: docs/develop/plugins/wasm/capabilities.md ================================================ --- title: Plugin Capabilities --- # Plugin Capabilities ## Capability Types Declare required capabilities in `package.json`: | Capability | Description | Status | | --------------- | ---------------------------------------- | ------------------------------- | | `dataRead` | Read Signal K data model | Supported | | `dataWrite` | Emit delta messages | Supported | | `storage` | Write to VFS (`vfs-only`) | Supported | | `httpEndpoints` | Register custom HTTP endpoints | Supported | | `staticFiles` | Serve HTML/CSS/JS from `public/` folder | Supported | | `network` | HTTP requests (via as-fetch) | Supported (AssemblyScript only) | | `putHandlers` | Register PUT handlers for vessel control | Supported | | `rawSockets` | UDP socket access for radar, NMEA, etc. | Supported | | `serialPorts` | Serial port access | Planned | ## Network API (AssemblyScript) AssemblyScript plugins can make HTTP requests using the `as-fetch` library integrated into the SDK. **Requirements:** - Plugin must declare `"network": true` in manifest - Server must be running Node.js 18+ (for native fetch support) - Import network functions from SDK - Must add `"transform": ["as-fetch/transform"]` to `asconfig.json` options - Must set `"exportRuntime": true` in `asconfig.json` options **Example: HTTP GET Request** ```typescript import { httpGet, hasNetworkCapability } from '@signalk/assemblyscript-plugin-sdk/assembly/network' import { debug, setError } from '@signalk/assemblyscript-plugin-sdk/assembly' class MyPlugin extends Plugin { start(config: string): i32 { // Always check capability first if (!hasNetworkCapability()) { setError('Network capability not granted') return 1 } // Make HTTP GET request const response = httpGet('https://api.example.com/data') if (response === null) { setError('HTTP request failed') return 1 } debug('Received: ' + response) return 0 } } ``` **Available Network Functions:** ```typescript // Check if network capability is granted hasNetworkCapability(): boolean // HTTP GET request - returns response body or null on error httpGet(url: string): string | null // HTTP POST request - returns status code or -1 on error httpPost(url: string, body: string): i32 // HTTP POST with response - returns response body or null httpPostWithResponse(url: string, body: string): string | null // HTTP PUT request - returns status code or -1 on error httpPut(url: string, body: string): i32 // HTTP DELETE request - returns status code or -1 on error httpDelete(url: string): i32 // Advanced HTTP request with full control httpRequest( url: string, method: string, body: string | null, contentType: string | null ): HttpResponse | null ``` **Build Configuration (asconfig.json):** For plugins using network capability: ```json { "targets": { "release": { "outFile": "build/plugin.wasm", "optimize": true, "shrinkLevel": 2, "runtime": "stub" } }, "options": { "bindings": "esm", "exportRuntime": true, "transform": ["as-fetch/transform"] } } ``` **Manifest Configuration:** ```json { "name": "my-plugin", "wasmCapabilities": { "network": true }, "dependencies": { "@signalk/assemblyscript-plugin-sdk": "^0.2.0", "as-fetch": "^2.1.4" } } ``` ## Raw Sockets API (UDP) The `rawSockets` capability enables direct UDP socket access for plugins that need to communicate with devices like: - Marine radars (Navico, Raymarine, Furuno, Garmin) - NMEA 0183 over UDP - AIS receivers - Other marine electronics using UDP multicast **Requirements:** - Plugin must declare `"rawSockets": true` in manifest - Sockets are non-blocking (poll-based receive) - Automatic cleanup when plugin stops **Manifest Configuration:** ```json { "name": "my-radar-plugin", "wasmManifest": "plugin.wasm", "wasmCapabilities": { "rawSockets": true, "dataWrite": true } } ``` **FFI Functions Available:** | Function | Signature | Description | | ------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------- | | `sk_udp_create` | `(type: i32) -> i32` | Create socket (0=udp4, 1=udp6). Returns socket_id or -1 | | `sk_udp_bind` | `(socket_id, port) -> i32` | Bind to port (0=any). Returns 0 or -1 | | `sk_udp_join_multicast` | `(socket_id, addr_ptr, addr_len, iface_ptr, iface_len) -> i32` | Join multicast group | | `sk_udp_leave_multicast` | `(socket_id, addr_ptr, addr_len, iface_ptr, iface_len) -> i32` | Leave multicast group | | `sk_udp_set_multicast_ttl` | `(socket_id, ttl) -> i32` | Set multicast TTL | | `sk_udp_set_multicast_loopback` | `(socket_id, enabled) -> i32` | Enable/disable loopback | | `sk_udp_set_broadcast` | `(socket_id, enabled) -> i32` | Enable/disable broadcast | | `sk_udp_send` | `(socket_id, addr_ptr, addr_len, port, data_ptr, data_len) -> i32` | Send datagram | | `sk_udp_recv` | `(socket_id, buf_ptr, buf_max_len, addr_out_ptr, port_out_ptr) -> i32` | Receive datagram (non-blocking) | | `sk_udp_pending` | `(socket_id) -> i32` | Get number of buffered datagrams | | `sk_udp_close` | `(socket_id) -> void` | Close socket | **Rust Example:** ```rust #[link(wasm_import_module = "env")] extern "C" { fn sk_udp_create(socket_type: i32) -> i32; fn sk_udp_bind(socket_id: i32, port: u16) -> i32; fn sk_udp_join_multicast( socket_id: i32, addr_ptr: *const u8, addr_len: usize, iface_ptr: *const u8, iface_len: usize ) -> i32; fn sk_udp_recv( socket_id: i32, buf_ptr: *mut u8, buf_max_len: usize, addr_out_ptr: *mut u8, port_out_ptr: *mut u16 ) -> i32; fn sk_udp_close(socket_id: i32); } // Example: Radar discovery fn start_radar_locator() -> i32 { // Create UDP socket let socket_id = unsafe { sk_udp_create(0) }; // udp4 if socket_id < 0 { return -1; } // Bind to radar discovery port if unsafe { sk_udp_bind(socket_id, 6878) } < 0 { return -1; } // Join radar multicast group let group = "239.254.2.0"; let iface = ""; if unsafe { sk_udp_join_multicast(socket_id, group.as_ptr(), group.len(), iface.as_ptr(), iface.len()) } < 0 { return -1; } socket_id } ``` **Important Notes:** - Receive is non-blocking - returns 0 if no data available - Incoming datagrams are buffered (max 1000 per socket) - Oldest datagrams are dropped if buffer is full - All sockets are automatically closed when plugin stops - Use `sk_udp_pending()` to check if data is available before calling `sk_udp_recv()` ## Raw Sockets API (TCP) The `rawSockets` capability also enables TCP socket access for plugins that need persistent connections to devices: - Marine radars with TCP control (Furuno, Garmin) - Devices requiring handshake/login protocols - Any marine electronics using TCP TCP sockets support both **line-buffered mode** (for text protocols with `\r\n` terminators) and **raw mode** (for binary protocols). **FFI Functions Available:** | Function | Signature | Description | | --------------------------- | ---------------------------------------------- | ------------------------------------------------------------ | | `sk_tcp_create` | `() -> i32` | Create TCP socket. Returns socket_id or -1 | | `sk_tcp_connect` | `(socket_id, addr_ptr, addr_len, port) -> i32` | Initiate connection (non-blocking). Returns 0 or -1 | | `sk_tcp_connected` | `(socket_id) -> i32` | Check connection status. Returns 1 if connected, 0 otherwise | | `sk_tcp_set_line_buffering` | `(socket_id, enabled) -> i32` | Set buffering mode (1=line, 0=raw). Default: line | | `sk_tcp_send` | `(socket_id, data_ptr, data_len) -> i32` | Send data. Returns bytes sent or -1 | | `sk_tcp_recv_line` | `(socket_id, buf_ptr, buf_max_len) -> i32` | Receive complete line (line mode). Returns len or 0 | | `sk_tcp_recv_raw` | `(socket_id, buf_ptr, buf_max_len) -> i32` | Receive raw data (raw mode). Returns len or 0 | | `sk_tcp_pending` | `(socket_id) -> i32` | Get buffered item count | | `sk_tcp_close` | `(socket_id) -> void` | Close socket | **Rust Example:** ```rust #[link(wasm_import_module = "env")] extern "C" { fn sk_tcp_create() -> i32; fn sk_tcp_connect(socket_id: i32, addr_ptr: *const u8, addr_len: usize, port: u16) -> i32; fn sk_tcp_connected(socket_id: i32) -> i32; fn sk_tcp_set_line_buffering(socket_id: i32, enabled: i32) -> i32; fn sk_tcp_send(socket_id: i32, data_ptr: *const u8, data_len: usize) -> i32; fn sk_tcp_recv_line(socket_id: i32, buf_ptr: *mut u8, buf_max_len: usize) -> i32; fn sk_tcp_recv_raw(socket_id: i32, buf_ptr: *mut u8, buf_max_len: usize) -> i32; fn sk_tcp_pending(socket_id: i32) -> i32; fn sk_tcp_close(socket_id: i32); } // Example: Furuno radar control connection fn connect_furuno_radar(ip: &str, port: u16) -> i32 { // Create TCP socket let socket_id = unsafe { sk_tcp_create() }; if socket_id < 0 { return -1; } // Initiate connection (non-blocking) if unsafe { sk_tcp_connect(socket_id, ip.as_ptr(), ip.len(), port) } < 0 { return -1; } socket_id } fn poll_connection(socket_id: i32) { // Check if connected if unsafe { sk_tcp_connected(socket_id) } != 1 { return; // Still connecting } // Send command with \r\n terminator let cmd = "$S69,2,0,0,60,300,0\r\n"; unsafe { sk_tcp_send(socket_id, cmd.as_ptr(), cmd.len()) }; // Receive response line let mut buf = [0u8; 256]; let len = unsafe { sk_tcp_recv_line(socket_id, buf.as_mut_ptr(), buf.len()) }; if len > 0 { // Process response } } ``` **Important Notes:** - Connection is non-blocking - poll `sk_tcp_connected()` until connected - Line-buffered mode (default) splits incoming data on `\r\n` or `\n` - Raw mode returns data as it arrives (for binary protocols) - Use `sk_tcp_pending()` to check if data is available - All sockets are automatically closed when plugin stops ## PUT Handlers API WASM plugins can register PUT handlers to respond to PUT requests from clients, enabling vessel control and configuration management. **Requirements:** - Plugin must declare `"putHandlers": true` in manifest - Import PUT handler functions from FFI - Register handlers during `plugin_start()` - Export handler functions with correct naming convention **Manifest Configuration:** ```json { "name": "my-plugin", "wasmManifest": "plugin.wasm", "wasmCapabilities": { "putHandlers": true } } ``` **Handler Naming Convention:** **Format:** `handle_put_{context}_{path}` - Replace all dots (`.`) with underscores (`_`) - Convert to lowercase (recommended) **Examples:** | Context | Path | Handler Function Name | | -------------- | --------------------------------------- | --------------------------------------------------------------- | | `vessels.self` | `navigation.anchor.position` | `handle_put_vessels_self_navigation_anchor_position` | | `vessels.self` | `steering.autopilot.target.headingTrue` | `handle_put_vessels_self_steering_autopilot_target_headingTrue` | **Response Format:** ```json { "state": "COMPLETED", "statusCode": 200, "message": "Operation successful" } ``` - `state` - Request state: `COMPLETED` or `PENDING` - `statusCode` - HTTP status code (200, 400, 403, 500, 501) - `message` - Human-readable message (optional) ## Storage API Plugins have access to isolated virtual filesystem: ```rust use std::fs; fn save_state() { // Plugin sees "/" as its VFS root fs::write("/data/state.json", state_json).unwrap(); } fn load_state() -> String { fs::read_to_string("/data/state.json").unwrap_or_default() } ``` **VFS Structure:** ``` / (VFS root) ├── data/ # Persistent storage ├── config/ # Plugin-managed config └── tmp/ # Temporary files ``` ## Delta Emission Emit delta messages to update Signal K data: ```rust fn emit_position_delta() { let delta = r#"{ "context": "vessels.self", "updates": [{ "source": { "label": "example-wasm", "type": "plugin" }, "timestamp": "2025-12-01T10:00:00.000Z", "values": [{ "path": "navigation.position", "value": { "latitude": 60.1, "longitude": 24.9 } }] }] }"#; handle_message(&delta); } ``` ================================================ FILE: docs/develop/plugins/wasm/deltas.md ================================================ --- title: Deltas --- # Working with Signal K Deltas WASM plugins can both **emit** and **receive** Signal K deltas. This page covers both directions. ## Emitting Deltas Use the `emit()` function to send delta messages to the Signal K server: ```typescript import { emit, createSimpleDelta, SK_VERSION_V1, SK_VERSION_V2 } from '@signalk/assemblyscript-plugin-sdk/assembly' // Emit a v1 delta (default - for regular navigation data) const tempDelta = createSimpleDelta('environment.outside.temperature', '288.15') emit(tempDelta) // Emit a v2 delta (for Course API and v2-specific paths) const courseDelta = createSimpleDelta( 'navigation.course.nextPoint', positionJson ) emit(courseDelta, SK_VERSION_V2) ``` **Note:** Plugins should NOT include `source` or `timestamp` in emitted deltas. The server automatically: - Sets `$source` to the plugin ID - Fills in `timestamp` with the current time ### Signal K v1 vs v2 Deltas The `emit()` function accepts an optional second parameter to specify the Signal K version: | Version | Constant | Use Case | | ------------ | --------------- | ------------------------------------------------------------------------------------------ | | v1 (default) | `SK_VERSION_V1` | Regular navigation data: `navigation.*`, `environment.*`, `electrical.*`, etc. | | v2 | `SK_VERSION_V2` | Course API paths and v2-specific data that should not be mixed into the v1 full data model | **Why does this matter?** - **v1 deltas** update the full Signal K data model and are available via the REST API and WebSocket subscriptions - **v2 deltas** are emitted as events for v2 API subscribers without mixing into the v1 data model Most plugins should use v1 (the default). Only use v2 when emitting Course API data or other v2-specific paths. This mirrors the TypeScript plugin API where `handleMessage()` accepts an optional `skVersion` parameter. --- ## Receiving Deltas WASM plugins can subscribe to receive Signal K deltas, enabling them to react to navigation data changes, course updates, sensor readings, and other vessel data in real-time. ## Implementing a Delta Handler Export a `delta_handler()` function to receive deltas: ```typescript // assembly/index.ts // Plugin state let vesselLat: f64 = 0.0 let vesselLon: f64 = 0.0 let hasPosition: bool = false export function delta_handler(deltaJson: string): void { // Check for position updates if (deltaJson.indexOf('"path":"navigation.position"') >= 0) { const lat = parseFloat64FromJson(deltaJson, 'latitude') const lon = parseFloat64FromJson(deltaJson, 'longitude') if (lat !== 0.0 || lon !== 0.0) { vesselLat = lat vesselLon = lon hasPosition = true debug('Position updated: ' + lat.toString() + ', ' + lon.toString()) } } // Check for course nextPoint if (deltaJson.indexOf('"path":"navigation.course.nextPoint"') >= 0) { // Extract destination coordinates and perform calculations // ... } // Check for speedOverGround if (deltaJson.indexOf('"navigation.speedOverGround"') >= 0) { const speed = parseFloat64FromJson(deltaJson, 'value') // Process speed data } } // Helper function to parse float from JSON function parseFloat64FromJson(json: string, key: string): f64 { const searchKey = '"' + key + '":' const match = json.indexOf(searchKey) if (match < 0) return 0.0 let start = match + searchKey.length while ( start < json.length && (json.charCodeAt(start) == 32 || json.charCodeAt(start) == 9) ) { start++ } let end = start while (end < json.length) { const c = json.charCodeAt(end) if (c == 44 || c == 125 || c == 93) break // comma, }, ] end++ } const numStr = json.substring(start, end).trim() return parseFloat(numStr) } ``` ## Received Delta JSON Format Deltas received by `delta_handler()` include `$source` and `timestamp` (added by the server): ```json { "context": "vessels.self", "updates": [ { "$source": "n2k-on-ve.can-socket.43", "timestamp": "2024-01-15T12:30:00.000Z", "values": [ { "path": "navigation.position", "value": { "latitude": -17.68, "longitude": 177.39 } }, { "path": "navigation.speedOverGround", "value": 5.2 } ] } ] } ``` ## Common Use Cases 1. **Course Calculations** - React to `navigation.course.nextPoint` and `navigation.position` to calculate bearing, distance, XTE 2. **Anchor Watch** - Monitor `navigation.position` and compare to anchor position 3. **Speed Alerts** - Watch `navigation.speedOverGround` for threshold breaches 4. **Environment Monitoring** - Track `environment.wind.*`, `environment.water.temperature`, etc. ## Detecting Cleared Values When values are cleared (e.g., destination removed), the server sends `null` values: ```typescript export function delta_handler(deltaJson: string): void { if (deltaJson.indexOf('"path":"navigation.course.nextPoint"') >= 0) { // Try to extract position first const lat = parseFloat64FromJson(deltaJson, 'latitude') const lon = parseFloat64FromJson(deltaJson, 'longitude') if (lat !== 0.0 || lon !== 0.0) { // Valid position - update state nextPointLat = lat nextPointLon = lon hasDestination = true } else { // Check if this is a null/clear operation const pathIdx = deltaJson.indexOf('"path":"navigation.course.nextPoint"') const checkRange = deltaJson.substring( pathIdx, Math.min(pathIdx + 100, deltaJson.length) as i32 ) if (checkRange.indexOf('"value":null') >= 0) { hasDestination = false debug('Destination cleared') } } } } ``` ## Performance Considerations - **Filter Early** - Check for relevant paths before parsing to minimize processing - **State Caching** - Store parsed values in global variables rather than re-parsing - **Debouncing** - High-frequency data (GPS at 10Hz) may benefit from debouncing calculations ================================================ FILE: docs/develop/plugins/wasm/go.md ================================================ --- title: Go/TinyGo Plugins --- # Creating Go/TinyGo Plugins Go plugins use TinyGo, a Go compiler designed for small environments including WebAssembly. ## Step 1: Install TinyGo Download from https://tinygo.org/getting-started/install/ ```bash # Verify installation tinygo version ``` ## Step 2: Create Project Structure ``` my-go-plugin/ ├── main.go # Plugin code ├── go.mod # Go module ├── package.json # npm package manifest ├── public/ # Static web assets (optional) │ └── index.html └── README.md ``` ## Step 3: Create go.mod ```go module my-go-plugin go 1.21 ``` ## Step 4: Create main.go ```go package main import ( "encoding/json" "unsafe" ) // FFI Imports from Signal K host //go:wasmimport env sk_debug func sk_debug(ptr *byte, len uint32) //go:wasmimport env sk_set_status func sk_set_status(ptr *byte, len uint32) //go:wasmimport env sk_set_error func sk_set_error(ptr *byte, len uint32) //go:wasmimport env sk_handle_message func sk_handle_message(ptr *byte, len uint32) // Helper wrappers func debug(msg string) { if len(msg) > 0 { sk_debug(unsafe.StringData(msg), uint32(len(msg))) } } func setStatus(msg string) { if len(msg) > 0 { sk_set_status(unsafe.StringData(msg), uint32(len(msg))) } } func handleMessage(msg string) { if len(msg) > 0 { sk_handle_message(unsafe.StringData(msg), uint32(len(msg))) } } // Memory allocation for string passing //export allocate func allocate(size uint32) *byte { buf := make([]byte, size) return &buf[0] } //export deallocate func deallocate(ptr *byte, size uint32) { // With leaking GC, memory is reclaimed when module unloads } // Plugin exports //export plugin_id func plugin_id(outPtr *byte, maxLen uint32) int32 { return writeString("my-go-plugin", outPtr, maxLen) } //export plugin_name func plugin_name(outPtr *byte, maxLen uint32) int32 { return writeString("My Go Plugin", outPtr, maxLen) } //export plugin_schema func plugin_schema(outPtr *byte, maxLen uint32) int32 { schema := `{"type":"object","properties":{}}` return writeString(schema, outPtr, maxLen) } //export plugin_start func plugin_start(configPtr *byte, configLen uint32) int32 { debug("Go plugin starting") setStatus("Running") // Emit a test delta delta := `{"updates":[{"values":[{"path":"test.goPlugin","value":"hello from Go"}]}]}` handleMessage(delta) return 0 } //export plugin_stop func plugin_stop() int32 { debug("Go plugin stopped") setStatus("Stopped") return 0 } // Helper: write string to output buffer func writeString(s string, ptr *byte, maxLen uint32) int32 { bytes := []byte(s) length := len(bytes) if uint32(length) > maxLen { length = int(maxLen) } dst := unsafe.Slice(ptr, length) copy(dst, bytes[:length]) return int32(length) } // Required for TinyGo WASM func main() {} ``` ## Step 5: Create package.json ```json { "name": "my-go-wasm-plugin", "version": "0.1.0", "description": "My Go WASM plugin", "keywords": ["signalk-wasm-plugin"], "wasmManifest": "plugin.wasm", "wasmCapabilities": { "dataRead": true, "dataWrite": true, "storage": "vfs-only" } } ``` > **Note**: The package name can be anything - there's no requirement for `@signalk/` scope. The `wasmManifest` field is what identifies this as a WASM plugin. ## Step 6: Build ```bash # Release build (smaller, optimized) tinygo build -o plugin.wasm -target=wasip1 -gc=leaking -no-debug main.go # Debug build (for development) tinygo build -o plugin.wasm -target=wasip1 main.go ``` ## Step 7: Install **Option 1: Symlink (Recommended for Development)** ```bash cd ~/.signalk/node_modules ln -s /path/to/your/my-go-wasm-plugin my-go-wasm-plugin ``` **Option 2: Direct Copy** ```bash mkdir -p ~/.signalk/node_modules/my-go-wasm-plugin cp plugin.wasm package.json ~/.signalk/node_modules/my-go-wasm-plugin/ ``` ## Go FFI Interface Reference Signal K provides these FFI imports in the `env` module: | Function | Parameters | Description | | ------------------------------- | ------------ | ----------------------------- | | `sk_debug` | `(ptr, len)` | Log debug message | | `sk_set_status` | `(ptr, len)` | Set plugin status | | `sk_set_error` | `(ptr, len)` | Set error message | | `sk_handle_message` | `(ptr, len)` | Emit delta message | | `sk_register_resource_provider` | `(ptr, len)` | Register as resource provider | ## Required Plugin Exports Your plugin MUST export: | Export | Signature | Description | | --------------- | ------------------------------------ | ------------------ | | `plugin_id` | `(out_ptr, max_len) -> len` | Return plugin ID | | `plugin_name` | `(out_ptr, max_len) -> len` | Return plugin name | | `plugin_schema` | `(out_ptr, max_len) -> len` | Return JSON schema | | `plugin_start` | `(config_ptr, config_len) -> status` | Start plugin | | `plugin_stop` | `() -> status` | Stop plugin | | `allocate` | `(size) -> ptr` | Allocate memory | | `deallocate` | `(ptr, size)` | Free memory | ## Optional Plugin Exports Your plugin MAY export: | Export | Signature | Description | | ---------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `poll` | `() -> status` | Called every 1 second while plugin is running. Useful for polling hardware, sockets, or external systems. Return 0 for success, non-zero for errors. | | `http_endpoints` | `() -> json` | Return JSON array of HTTP endpoint definitions | | `delta_handler` | `(delta_ptr, delta_len)` | Receives Signal K deltas as JSON strings. Called for every delta emitted by the server. | ## TinyGo Limitations TinyGo is a subset of Go. Notable limitations: - No reflection (limited `encoding/json` support) - No goroutines with WASI Preview 1 - Garbage collector options: `leaking` (recommended), `conservative` - Some standard library packages unavailable See https://tinygo.org/docs/reference/lang-support/ for details. ## Additional Resources See the example-routes-waypoints plugin in `examples/wasm-plugins/example-routes-waypoints/` for a complete resource provider plugin. ================================================ FILE: docs/develop/plugins/wasm/http_endpoints.md ================================================ --- title: HTTP Endpoints --- # HTTP Endpoints WASM plugins can register custom HTTP endpoints to provide REST APIs or serve dynamic content. This is useful for: - Providing plugin-specific APIs - Implementing webhook receivers - Creating custom data queries - Building interactive dashboards ## Registering HTTP Endpoints Export an `http_endpoints()` function that returns a JSON array of endpoint definitions: ```typescript // assembly/index.ts export function http_endpoints(): string { return `[ { "method": "GET", "path": "/api/data", "handler": "handle_get_data" }, { "method": "POST", "path": "/api/update", "handler": "handle_post_update" } ]` } ``` ## Implementing HTTP Handlers Handler functions receive a request context and return an HTTP response: ```typescript export function handle_get_data(requestPtr: usize, requestLen: usize): string { // 1. Decode request from WASM memory const requestBytes = new Uint8Array(i32(requestLen)) for (let i: i32 = 0; i < i32(requestLen); i++) { requestBytes[i] = load(requestPtr + i) } const requestJson = String.UTF8.decode(requestBytes.buffer) // 2. Parse request (contains method, path, query, params, body, headers) // Simple example: extract query parameter let filter = '' const filterIndex = requestJson.indexOf('"filter"') if (filterIndex >= 0) { // Extract the filter value from JSON // (In production, use proper JSON parsing) } // 3. Process request and build response data const data = { items: [ { id: 1, value: 'Item 1' }, { id: 2, value: 'Item 2' } ], count: 2 } const bodyJson = JSON.stringify(data) // 4. Escape JSON for embedding in response string const escapedBody = bodyJson .replaceAll('"', '\\"') .replaceAll('\n', '\\n') .replaceAll('\r', '\\r') // 5. Return HTTP response (status, headers, body) return `{ "statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "${escapedBody}" }` } export function handle_post_update( requestPtr: usize, requestLen: usize ): string { const requestBytes = new Uint8Array(i32(requestLen)) for (let i: i32 = 0; i < i32(requestLen); i++) { requestBytes[i] = load(requestPtr + i) } const requestJson = String.UTF8.decode(requestBytes.buffer) // Process POST body and update state // ... return `{ "statusCode": 200, "headers": {"Content-Type": "application/json"}, "body": "{\\"success\\":true}" }` } ``` ## Request Context Format The request context is a JSON object with: ```json { "method": "GET", "path": "/api/logs", "query": { "lines": "100", "filter": "error" }, "params": {}, "body": null, "headers": { "user-agent": "Mozilla/5.0...", "accept": "application/json" } } ``` ## Response Format Handler functions must return a JSON string with: ```json { "statusCode": 200, "headers": { "Content-Type": "application/json", "Cache-Control": "no-cache" }, "body": "{\"data\": \"value\"}" } ``` **Important Notes:** - The `body` field must be a JSON-escaped string - Use double escaping for quotes: `\\"` not `"` - Endpoints are mounted at `/plugins/your-plugin-id/api/...` - From browser, fetch from absolute path: `/plugins/your-plugin-id/api/logs` ## String Memory Management The server uses the **AssemblyScript loader** for automatic string handling: **For plugin metadata (id, name, schema, http_endpoints):** - Return AssemblyScript strings directly - Server automatically decodes with `__getString()` **For HTTP handlers:** - Receive: `(requestPtr: usize, requestLen: usize)` - raw memory pointer - Manually decode UTF-8 bytes from WASM memory - Return: AssemblyScript string with escaped JSON - Server automatically decodes with `__getString()` **Why manual decoding for handlers?** The request is passed as raw UTF-8 bytes for efficiency, but the response is returned as an AssemblyScript string (UTF-16LE) which the loader decodes automatically. ## Testing Your Endpoints ```bash # Test GET endpoint curl http://localhost:3000/plugins/my-plugin/api/data?filter=test # Test POST endpoint curl -X POST http://localhost:3000/plugins/my-plugin/api/update \ -H "Content-Type: application/json" \ -d '{"value": 123}' ``` ## Security Considerations - Endpoints are sandboxed - no direct file system access - Memory is isolated - cannot access other plugins - Validate all input from requests - Implement authentication if handling sensitive data - Set appropriate CORS headers if needed ================================================ FILE: docs/develop/plugins/wasm/integration_guide.md ================================================ --- title: Integration Guide for WASM Plugins --- # Integration Guide for WASM Plugins ## Static File Serving Plugins can serve HTML, CSS, JavaScript and other static files: **Structure:** ``` @signalk/my-plugin/ ├── public/ # Automatically served at /plugins/my-plugin/ │ ├── index.html │ ├── style.css │ └── app.js ├── plugin.wasm └── package.json ``` **Access:** `http://localhost:3000/plugins/my-plugin/` serves `public/index.html` ## Resource Providers WASM plugins can act as **resource providers** for Signal K resources like weather data, routes, waypoints, or custom resource types. ### Enabling Resource Provider Capability Add `resourceProvider: true` to your package.json: ```json { "wasmCapabilities": { "network": true, "dataRead": true, "dataWrite": true, "resourceProvider": true } } ``` ### Registering as a Resource Provider #### AssemblyScript ```typescript import { registerResourceProvider } from '@signalk/assemblyscript-plugin-sdk/assembly/resources' // In plugin start(): if (!registerResourceProvider('weather-forecasts')) { setError('Failed to register as resource provider') return 1 } ``` #### Rust ```rust #[link(wasm_import_module = "env")] extern "C" { fn sk_register_resource_provider(type_ptr: *const u8, type_len: usize) -> i32; } pub fn register_resource_provider(resource_type: &str) -> bool { let bytes = resource_type.as_bytes(); unsafe { sk_register_resource_provider(bytes.as_ptr(), bytes.len()) == 1 } } // In plugin_start(): if !register_resource_provider("weather-forecasts") { // Registration failed return 1; } ``` ### Implementing Resource Handlers After registering, your plugin must export these handler functions: #### `resources_list_resources` - List resources matching a query **AssemblyScript:** ```typescript export function resources_list_resources(queryJson: string): string { // queryJson: {"bbox": [...], "distance": 1000, ...} // Return JSON object: {"resource-id-1": {...}, "resource-id-2": {...}} return '{"forecast-1": {"name": "Current Weather", "type": "weather"}}' } ``` **Rust:** ```rust #[no_mangle] pub extern "C" fn resources_list_resources( request_ptr: *const u8, request_len: usize, response_ptr: *mut u8, response_max_len: usize, ) -> i32 { // Parse query, build response let response = r#"{"forecast-1": {"name": "Current Weather"}}"#; write_string(response, response_ptr, response_max_len) } ``` #### `resources_get_resource` - Get a single resource **AssemblyScript:** ```typescript export function resources_get_resource(requestJson: string): string { // requestJson: {"id": "forecast-1", "property": null} return '{"name": "Current Weather", "temperature": 20.5, "humidity": 0.65}' } ``` #### `resources_set_resource` - Create or update a resource **AssemblyScript:** ```typescript export function resources_set_resource(requestJson: string): string { // requestJson: {"id": "forecast-1", "value": {...}} // Return empty string on success, or error message return '' } ``` #### `resources_delete_resource` - Delete a resource **AssemblyScript:** ```typescript export function resources_delete_resource(requestJson: string): string { // requestJson: {"id": "forecast-1"} return '' } ``` ### Accessing Resources via HTTP Once registered, resources are available at: ``` GET /signalk/v2/api/resources/{type} # List all GET /signalk/v2/api/resources/{type}/{id} # Get one POST /signalk/v2/api/resources/{type}/{id} # Create/update DELETE /signalk/v2/api/resources/{type}/{id} # Delete ``` ### Standard vs Custom Resource Types Signal K defines standard resource types with validation: - `routes` - Navigation routes - `waypoints` - Navigation waypoints - `notes` - Freeform notes - `regions` - Geographic regions - `charts` - Chart metadata Custom types (like `weather-forecasts`) have no schema validation and can contain any JSON structure. ## Weather Providers WASM plugins can act as **weather providers** for Signal K's specialized Weather API. ### Weather Provider vs Resource Provider | Feature | Weather Provider | Resource Provider | | ---------- | ------------------------------------------ | ---------------------------------- | | API Path | `/signalk/v2/api/weather/*` | `/signalk/v2/api/resources/{type}` | | Methods | getObservations, getForecasts, getWarnings | list, get, set, delete | | Use Case | Standardized weather data | Generic data storage | | Capability | `weatherProvider: true` | `resourceProvider: true` | | FFI | `sk_register_weather_provider` | `sk_register_resource_provider` | ### Enabling Weather Provider Capability ```json { "wasmCapabilities": { "network": true, "dataWrite": true, "weatherProvider": true } } ``` ### Implementing Weather Handler Exports Your plugin must export these handler functions: #### `weather_get_observations` - Get current weather observations ```typescript export function weather_get_observations(requestJson: string): string { // requestJson: {"position": {"latitude": 60.17, "longitude": 24.94}, "options": {...}} return ( '[{"date":"2025-01-01T00:00:00Z","type":"observation","description":"Clear sky",' + '"outside":{"temperature":280.15,"relativeHumidity":0.65,"pressure":101300,"cloudCover":0.1},' + '"wind":{"speedTrue":5.0,"directionTrue":1.57}}]' ) } ``` #### `weather_get_forecasts` - Get weather forecasts ```typescript export function weather_get_forecasts(requestJson: string): string { // requestJson: {"position": {...}, "type": "daily"|"point", "options": {"maxCount": 7}} return '[{"date":"...","type":"daily","outside":{...},"wind":{...}}]' } ``` #### `weather_get_warnings` - Get weather warnings/alerts ```typescript export function weather_get_warnings(requestJson: string): string { // requestJson: {"position": {...}} return '[]' } ``` ### Weather Data Format #### Observation/Forecast Object ```json { "date": "2025-12-05T10:00:00.000Z", "type": "observation", "description": "light rain", "outside": { "temperature": 275.15, "minTemperature": 273.0, "maxTemperature": 278.0, "feelsLikeTemperature": 272.0, "relativeHumidity": 0.85, "pressure": 101300, "cloudCover": 0.75 }, "wind": { "speedTrue": 5.2, "directionTrue": 3.14, "gust": 8.0 } } ``` Units: - Temperature: Kelvin - Humidity: Ratio (0-1) - Pressure: Pascals - Wind speed: m/s - Wind direction: Radians #### Warning Object ```json { "startTime": "2025-12-05T10:00:00.000Z", "endTime": "2025-12-05T18:00:00.000Z", "details": "Strong wind warning", "source": "Weather Service", "type": "Warning" } ``` ### Accessing Weather Data via HTTP ```bash # List providers curl http://localhost:3000/signalk/v2/api/weather/_providers # Get observations for a location curl "http://localhost:3000/signalk/v2/api/weather/observations?lat=60.17&lon=24.94" # Get daily forecasts curl "http://localhost:3000/signalk/v2/api/weather/forecasts/daily?lat=60.17&lon=24.94" # Get point-in-time forecasts curl "http://localhost:3000/signalk/v2/api/weather/forecasts/point?lat=60.17&lon=24.94" # Get weather warnings curl "http://localhost:3000/signalk/v2/api/weather/warnings?lat=60.17&lon=24.94" ``` ## Radar Providers WASM plugins can act as **radar providers** for Signal K's Radar API at `/signalk/v2/api/vessels/self/radars`. ### Enabling Radar Provider Capability ```json { "signalk": { "wasmCapabilities": { "radarProvider": true, "network": true } } } ``` ### Registering as a Radar Provider ```typescript // Declare the host function @external("env", "sk_register_radar_provider") declare function sk_register_radar_provider(namePtr: usize, nameLen: i32): i32; export function start(configJson: string): i32 { const name = "My Radar Plugin"; const nameBytes = String.UTF8.encode(name); const result = sk_register_radar_provider( changetype(nameBytes), nameBytes.byteLength ); if (result === 0) { sk_set_plugin_error("Failed to register as radar provider", 38); return 1; } return 0; } ``` ### Required Handler Exports ```typescript // Return JSON array of radar IDs this provider manages export function radar_get_radars(): string { return JSON.stringify(['radar-0', 'radar-1']) } // Return RadarInfo JSON for a specific radar export function radar_get_radar_info(requestJson: string): string { const info = { id: 'radar-0', name: 'Furuno DRS4D-NXT', brand: 'Furuno', status: 'transmit', spokesPerRevolution: 2048, maxSpokeLen: 1024, range: 2000, controls: { gain: { auto: false, value: 50 }, sea: { auto: true, value: 30 } } } return JSON.stringify(info) } ``` ### RadarInfo Interface ```typescript interface RadarInfo { id: string // Unique radar ID name: string // Display name brand?: string // Manufacturer status: 'off' | 'standby' | 'transmit' | 'warming' spokesPerRevolution: number // Spokes per rotation maxSpokeLen: number // Max spoke samples range: number // Current range (meters) controls: RadarControls // Current control values legend?: LegendEntry[] // Color legend for display streamUrl?: string // Optional external WebSocket URL } ``` ### Streaming Radar Spokes Radar spoke data arrives at ~60Hz (2048 spokes/rotation × 30-60 RPM). Plugins stream binary protobuf data directly to clients: ```typescript import { sk_radar_emit_spokes } from './signalk-api' // Called when spoke data received via UDP multicast function processSpokeData(radarId: string, spokeProtobuf: Uint8Array): void { sk_radar_emit_spokes(radarId, spokeProtobuf.buffer, spokeProtobuf.byteLength) } ``` Clients connect to the WebSocket stream: ```javascript const wsUrl = `ws://${location.host}/signalk/v2/api/vessels/self/radars/radar-0/stream` const ws = new WebSocket(wsUrl) ws.binaryType = 'arraybuffer' ws.onmessage = (event) => { const spokeData = new Uint8Array(event.data) // Decode and render spoke } ``` ================================================ FILE: docs/develop/plugins/wasm/rust.md ================================================ --- title: Rust Plugins --- # Creating Rust Plugins Rust is excellent for WASM plugins due to its zero-cost abstractions, memory safety, and mature WASM tooling. Signal K Rust plugins use **buffer-based FFI** for string passing, which differs from AssemblyScript's automatic string handling. ## Rust vs AssemblyScript: Key Differences | Aspect | AssemblyScript | Rust | | ----------------- | ----------------------- | ------------------------------- | | String passing | Automatic via AS loader | Manual buffer-based FFI | | Memory management | AS runtime handles | `allocate`/`deallocate` exports | | Binary size | 3-10 KB | 50-200 KB | | Target | `wasm32` (AS compiler) | `wasm32-wasip1` | ## Step 1: Project Structure Create a new Rust library project: ```bash cargo new --lib example-anchor-watch-rust cd example-anchor-watch-rust ``` ## Step 2: Configure Cargo.toml ```toml [package] name = "anchor_watch_rust" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" [profile.release] opt-level = "z" # Optimize for size lto = true # Link-time optimization strip = true # Strip symbols ``` ## Step 3: Implement Plugin (src/lib.rs) ```rust use std::cell::RefCell; use serde::{Deserialize, Serialize}; // ============================================================================= // FFI Imports - These MUST match what the Signal K runtime provides in "env" // ============================================================================= #[link(wasm_import_module = "env")] extern "C" { fn sk_debug(ptr: *const u8, len: usize); fn sk_set_status(ptr: *const u8, len: usize); fn sk_set_error(ptr: *const u8, len: usize); fn sk_handle_message(ptr: *const u8, len: usize); fn sk_register_put_handler( context_ptr: *const u8, context_len: usize, path_ptr: *const u8, path_len: usize ) -> i32; } // ============================================================================= // Helper wrappers for FFI functions // ============================================================================= fn debug(msg: &str) { unsafe { sk_debug(msg.as_ptr(), msg.len()); } } fn set_status(msg: &str) { unsafe { sk_set_status(msg.as_ptr(), msg.len()); } } fn set_error(msg: &str) { unsafe { sk_set_error(msg.as_ptr(), msg.len()); } } fn handle_message(msg: &str) { unsafe { sk_handle_message(msg.as_ptr(), msg.len()); } } fn register_put_handler(context: &str, path: &str) -> i32 { unsafe { sk_register_put_handler( context.as_ptr(), context.len(), path.as_ptr(), path.len() ) } } // ============================================================================= // Memory Allocation - REQUIRED for buffer-based string passing // ============================================================================= /// Allocate memory for string passing from host #[no_mangle] pub extern "C" fn allocate(size: usize) -> *mut u8 { let mut buf = Vec::with_capacity(size); let ptr = buf.as_mut_ptr(); std::mem::forget(buf); ptr } /// Deallocate memory #[no_mangle] pub extern "C" fn deallocate(ptr: *mut u8, size: usize) { unsafe { let _ = Vec::from_raw_parts(ptr, 0, size); } } // ============================================================================= // Plugin State // ============================================================================= thread_local! { static STATE: RefCell = RefCell::new(PluginState::default()); } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] struct PluginConfig { #[serde(default)] max_radius: f64, } #[derive(Debug, Default)] struct PluginState { config: PluginConfig, is_running: bool, } // ============================================================================= // Plugin Exports - Core plugin interface // ============================================================================= static PLUGIN_ID: &str = "my-rust-plugin"; static PLUGIN_NAME: &str = "My Rust Plugin"; static PLUGIN_SCHEMA: &str = r#"{ "type": "object", "properties": { "maxRadius": { "type": "number", "title": "Max Radius", "default": 50 } } }"#; /// Return the plugin ID (buffer-based) #[no_mangle] pub extern "C" fn plugin_id(out_ptr: *mut u8, out_max_len: usize) -> i32 { write_string(PLUGIN_ID, out_ptr, out_max_len) } /// Return the plugin name (buffer-based) #[no_mangle] pub extern "C" fn plugin_name(out_ptr: *mut u8, out_max_len: usize) -> i32 { write_string(PLUGIN_NAME, out_ptr, out_max_len) } /// Return the plugin JSON schema (buffer-based) #[no_mangle] pub extern "C" fn plugin_schema(out_ptr: *mut u8, out_max_len: usize) -> i32 { write_string(PLUGIN_SCHEMA, out_ptr, out_max_len) } /// Start the plugin with configuration #[no_mangle] pub extern "C" fn plugin_start(config_ptr: *const u8, config_len: usize) -> i32 { // Read config from buffer let config_json = unsafe { let slice = std::slice::from_raw_parts(config_ptr, config_len); String::from_utf8_lossy(slice).to_string() }; // Parse configuration let parsed_config: PluginConfig = match serde_json::from_str(&config_json) { Ok(c) => c, Err(e) => { set_error(&format!("Failed to parse config: {}", e)); return 1; } }; // Update state STATE.with(|state| { let mut s = state.borrow_mut(); s.config = parsed_config; s.is_running = true; }); debug("Plugin started successfully"); set_status("Running"); 0 // Success } /// Stop the plugin #[no_mangle] pub extern "C" fn plugin_stop() -> i32 { STATE.with(|state| { state.borrow_mut().is_running = false; }); debug("Plugin stopped"); set_status("Stopped"); 0 // Success } // ============================================================================= // Helper Functions // ============================================================================= /// Write string to output buffer, return bytes written fn write_string(s: &str, ptr: *mut u8, max_len: usize) -> i32 { let bytes = s.as_bytes(); let len = bytes.len().min(max_len); unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len); } len as i32 } ``` ## Step 4: Create package.json ```json { "name": "my-rust-wasm-plugin", "version": "0.1.0", "description": "My Rust WASM plugin for Signal K", "keywords": ["signalk-wasm-plugin"], "wasmManifest": "plugin.wasm", "wasmCapabilities": { "network": false, "storage": "vfs-only", "dataRead": true, "dataWrite": true, "putHandlers": true }, "author": "Your Name", "license": "Apache-2.0" } ``` > **Note**: The package name can be anything - there's no requirement for `@signalk/` scope. The `wasmManifest` field is what identifies this as a WASM plugin. ## Step 5: Build ```bash # Build with WASI Preview 1 target (required for Signal K) cargo build --release --target wasm32-wasip1 # Copy to plugin.wasm cp target/wasm32-wasip1/release/my_rust_plugin.wasm plugin.wasm ``` > **Important**: Use `wasm32-wasip1` target, NOT `wasm32-wasi`. Signal K requires WASI Preview 1. ## Step 6: Install **Option 1: Symlink (Recommended for Development)** ```bash cd ~/.signalk/node_modules ln -s /path/to/your/my-rust-wasm-plugin my-rust-wasm-plugin ``` **Option 2: Direct Copy** ```bash mkdir -p ~/.signalk/node_modules/my-rust-wasm-plugin cp plugin.wasm package.json ~/.signalk/node_modules/my-rust-wasm-plugin/ ``` **Option 3: NPM Package Install** ```bash npm pack npm install -g ./my-rust-wasm-plugin-0.1.0.tgz ``` ## Step 7: Enable in Admin UI 1. Navigate to **Server** → **Plugin Config** 2. Find "My Rust Plugin" 3. Click **Enable** 4. Configure settings 5. Click **Submit** ## Rust FFI Interface Reference Signal K provides these FFI imports in the `env` module: | Function | Parameters | Description | | ------------------------- | ---------------------------------------- | -------------------- | | `sk_debug` | `(ptr, len)` | Log debug message | | `sk_set_status` | `(ptr, len)` | Set plugin status | | `sk_set_error` | `(ptr, len)` | Set error message | | `sk_handle_message` | `(ptr, len)` | Emit delta message | | `sk_register_put_handler` | `(ctx_ptr, ctx_len, path_ptr, path_len)` | Register PUT handler | > **IMPORTANT: Use Exact Function Names** > > You MUST use the exact function names listed above. Common mistakes: > > - `sk_log_debug`, `sk_log_info`, `sk_log_warn` → Use `sk_debug` for all logging > - `sk_emit_delta` → Use `sk_handle_message` > - `sk_udp_recv_from` → Use `sk_udp_recv` > > There is only one logging function (`sk_debug`). If you need log levels, prefix your message: > > ```rust > debug("[INFO] Starting radar scan"); > debug("[WARN] Connection timeout"); > ``` ## Required Plugin Exports Your plugin MUST export: | Export | Signature | Description | | --------------- | ------------------------------------ | ------------------ | | `plugin_id` | `(out_ptr, max_len) -> len` | Return plugin ID | | `plugin_name` | `(out_ptr, max_len) -> len` | Return plugin name | | `plugin_schema` | `(out_ptr, max_len) -> len` | Return JSON schema | | `plugin_start` | `(config_ptr, config_len) -> status` | Start plugin | | `plugin_stop` | `() -> status` | Stop plugin | | `allocate` | `(size) -> ptr` | Allocate memory | | `deallocate` | `(ptr, size)` | Free memory | ## Optional Plugin Exports Your plugin MAY export: | Export | Signature | Description | | ---------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `poll` | `() -> status` | Called every 1 second while plugin is running. Useful for polling hardware, sockets, or external systems. Return 0 for success, non-zero for errors. | | `http_endpoints` | `() -> json` | Return JSON array of HTTP endpoint definitions | | `delta_handler` | `(delta_ptr, delta_len)` | Receives Signal K deltas as JSON strings. Called for every delta emitted by the server. | ## Additional Resources See the example-anchor-watch-rust plugin in `examples/wasm-plugins/example-anchor-watch-rust/` for a complete working plugin with PUT handlers. ================================================ FILE: docs/develop/plugins/weather_provider_plugins.md ================================================ --- title: Weather Providers --- # Weather Providers The Signal K server [Weather API](../rest-api/weather_api.md) provides a common set of operations for retrieving meteorological data via a "provider plugin" to facilitate communication with a weather service provider. A weather provider plugin is a Signal K server plugin that brokers communication with a weather provider. --- ## Weather Provider Interface For a plugin to be a weather provider it must implement the {@link @signalk/server-api!WeatherProvider | `WeatherProvider`} Interface which provides the Signal K server with methods to pass the details contained in API requests. **A weather provider MUST return data as defined by the OpenAPI definition.** > [!NOTE] > Multiple weather providers can be registered with the Signal K server to enable meteorogical data retrieval from multiple sources. ## Weather Provider Interface Methods Weather API requests made to the Signal K server will result in the plugin's {@link @signalk/server-api!WeatherProviderMethods | `WeatherProviderMethods`} being called. A weather provider plugin MUST implement ALL of the {@link @signalk/server-api!WeatherProviderMethods | `WeatherProviderMethods`}: - {@link @signalk/server-api!WeatherProviderMethods.getObservations | `getObservations(position, options)`} - {@link @signalk/server-api!WeatherProviderMethods.getForecasts | `getForecasts(position, type, options)`} - {@link @signalk/server-api!WeatherProviderMethods.getWarnings | `getWarnings(position)`} > [!NOTE] > The Weather Provider is responsible for implementing the methods and returning data in the required format! --- ## Registering a Weather Provider Now that the plugin has implemented the required interface and methods, it can be registered as a weather provider with the SignalK server. The plugin registers itself as a weather provider by calling the server's {@link @signalk/server-api!WeatherProviderRegistry.registerWeatherProvider | `registerWeatherProvider`} function during startup. Do this within the plugin `start()` method. _Example._ ```javascript import { WeatherProvider } from '@signalk/server-api' module.exports = function (app) { const weatherProvider: WeatherProvider = { name: 'MyWeatherService', methods: { getObservations: ( position: Position, options?: WeatherReqParams ) => { // fetch observation data from weather service return observations }, getForecasts: ( position: Position, type: WeatherForecastType, options?: WeatherReqParams ) => { // fetch forecasts data from weather service return forecasts }, getWarnings: () => { // Service does not provide weather warnings. throw new Error('Not supported!') } } } const plugin = { id: 'mypluginid', name: 'My Weather Provider plugin' start: (settings: any) => { app.registerWeatherProvider(weatherProvider) } } return plugin ``` ================================================ FILE: docs/develop/rest-api/README.md ================================================ --- title: REST APIs children: - conventions.md - autopilot_api.md - course_api.md - history_api.md - notifications_api.md - radar_api.md - resources_api.md - weather_api.md - plugin_api.md - ./proposed/README.md --- # REST APIs Modular, subdomain specific REST APIs were introduced in Signal K server version 2 to provide a way for applications and plugins perform operations and ensure that the Signal K data model is consistent. The OpenAPI definitions can be found under _Documentation -> OpenAPI_ in the server Admin UI. ### APIs available in Signal K server v2.0.0 and later: APIs are available via `/signalk/v2/api/` | API | Description | Endpoint | | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | [`Autopilot`](./autopilot_api.md) | Provide the ability to send common commands to an autopilot via a provider plugin. | `vessels/self/autopilot` | | [Course](./course_api.md) | Set a course, follow a route, advance to next point, etc. | `vessels/self/navigation/course` | | [History](./history_api.md) | Query historical data. | `history` | | [Radar](./radar_api.md) | View and control marine radar equipment via a provider plugin. _(In development)_ | `vessels/self/radars` | | [Resources](./resources_api.md) | Create, view, update and delete waypoints, routes, etc. | `resources` | | _[`Notifications`](notifications_api.md)_ | Provide the ability to raise, update and clear notifications from multiple sources. _[View PR](https://github.com/SignalK/signalk-server/pull/1560)_ | `notifications` | --- ================================================ FILE: docs/develop/rest-api/autopilot_api.md ================================================ --- title: Autopilot API --- # Autopilot API The Autopilot API defines the `autopilots` path under `self` _(e.g. `/signalk/v2/api/vessels/self/autopilots`)_ for representing information from one or more autopilot devices. The Autopilot API provides a mechanism for applications to issue requests to autopilot devices to perform common operations. Additionally, when multiple autopilot devices are present, each autopilot device is individually addressable. _Note: Autopilot provider plugins are required to enable the API operation and provide communication with autopilot devices. See [Autopilot Provider Plugins](../plugins/autopilot_provider_plugins.md) for details._ ## Common Operations The following operations are supported: - Setting the operating mode - Engaging / Disengaging the pilot - Setting / adjusting the course - Dodging port / starboard - Tacking / Gybing ## The _Default_ Autopilot To ensure a consistent API calling profile and to simplify client operations, the Autopilot API will assign a _default_ autopilot device which is accessible using the path `/signalk/v2/api/vessels/self/autopilots/_default`. - When only one autopilot is present, it will be automatically assigned as the _default_. - When multiple autopilots are present, and a _default_ is yet to be assigned, one will be assigned when: - An update is received from a provider plugin, the autopilot which is the source of the update will be assigned as the _default_. - An API request is received, the first autopilot device registered, is assigned as the _default_. - A request is sent to the `/_providers/_default` API endpoint _(see [Setting the Default Autopilot](#setting-the-default-provider))_. ### Getting the Default Autopilot Identifier To get the id of the _default_ autopilot, submit an HTTP `GET` request to `/signalk/v2/api/vessels/self/autopilots/_providers/_default`. _Example:_ ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots/_providers/_default" ``` _Response:_ ```JSON { "id":"raymarine-id" } ``` ### Setting an Autopilot as the Default To set / change the _default_ autopilot, submit an HTTP `POST` request to `/signalk/v2/api/vessels/self/autopilots/_providers/_default/{id}` where `{id}` is the identifier of the autopilot to use as the _default_. _Example:_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/_providers/_default/raymarine-id" ``` The autopilot with the supplied id will now be the target of requests made to `/signalk/v2/api/vessels/self/autopilots/_default/*`. ## Listing the available Autopilots To retrieve a list of installed autopilot devices, submit an HTTP `GET` request to `/signalk/v2/api/vessels/self/autopilots`. The response will be an object containing all the registered autopilot devices, keyed by their identifier, detailing the `provider` it is registered by and whether it is assigned as the _default_. ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots" ``` _Example: List of registered autopilots showing that `pypilot-id` is assigned as the default._ ```JSON { "pypilot-id": { "provider":"pypilot-provider", "isDefault": true }, "raymarine-id": { "provider":"raymarine-provider", "isDefault": false } } ``` ## Autopilot Deltas Deltas emitted by the Autopilot API will have the base path `steering.autopilot` with the `$source` containing the autopilot device identifier. Deltas are emitted for the following paths: - `steering.autopilot.defaultPilot` - `steering.autopilot.engaged` - `steering.autopilot.state` - `steering.autopilot.mode` - `steering.autopilot.target` - `steering.autopilot.availableActions` _Example: Deltas for `autopilot.engaged` from two autopilots (`raymarine-id`)._ ```JSON { "context":"vessels.self", "updates":[ { "$source":"pypilot-id", "timestamp":"2023-11-19T06:12:47.820Z", "values":[ {"path":"steering.autopilot.engaged","value":false} ] }, { "$source":"raymarine-id", "timestamp":"2023-11-19T06:12:47.820Z", "values":[ {"path":"steering.autopilot.engaged","value":true} ] } ] } ``` ## Autopilot Notifications The Autopilot API will provide notifications under the path `notifications.steering.autopilot` with the `$source` containing the autopilot device identifier. A set of normalised notification paths are defined to provide a consistant way for client apps to receive and process alarm messages. - `waypointAdvance` - `waypointArrival` - `routeComplete` - `xte` - `heading` - `wind` _Example:_ ```JSON { "context":"vessels.self", "updates":[ { "$source":"pypilot-id", "timestamp":"2023-11-19T06:12:47.820Z", "values":[ { "path": "notifications.steering.autopilot.waypointAdvance", "value": { "state": "alert", "method": ["sound"], "message": "Waypoint Advance" } } ] } ] } ``` ## Autopilot offline / unreachable If an autopilot device is not connected or unreachable, the provider for that autopilot device will set the `state` of the device to `off-line`. ## Autopilot Actions The Autopilot API allows providers to list all the "actions" that are supported by the device _(e.g. tack, gybe, etc)_ and their availability in the current state of operation. A set of normalised actions are defined to simplify client processing and UI trimming: - `dodge` - `tack` - `gybe` - `courseCurrentPoint` - `courseNextPoint` ```JSON { "options": { "states": [...], "modes": [...], "actions": [ {"id": "tack", "name": "Tack", "available": true}, {"id": "gybe", "name": "Gybe", "available": false} ] }, "state": "disabled", "mode": "wind", "target": 0.43, "engaged": true } ``` ## Autopilot Operations All API operations are invoked by issuing requests to: 1. `/signalk/v2/api/vessels/self/autopilots/_default/*` Targets the default autopilot device. OR 2. `/signalk/v2/api/vessels/self/autopilots/{id}/*` Target the autopilot with the supplied `{id}` _Example:_ ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots/_default/state" HTTP GET "/signalk/v2/api/vessels/self/autopilots/pypilot-id/mode" ``` ### Retrieving Autopilot Status To retrieve the current autopilot configuration as well as a list of available options for `state` and `mode` selections, submit an HTTP `GET` request to `/signalk/v2/api/vessels/self/autopilots/{id}`. ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots/{id}" ``` _Response:_ ```JSON { "options":{ "state":["enabled","disabled"], "mode":["gps","compass","wind"] }, "state":"disabled", "mode":"gps", "target": 0, "engaged": false } ``` Where: - `options` contains arrays of valid `state` and `mode` selection options - `state` represents the current state of the device - `mode` represents the current mode of the device - `target` represents the current target value with respect to the selected `mode` - `engaged` will be true when the autopilot is actively steering the vessel. ### Setting the Autopilot State Autopilot state can be set by submitting an HTTP `PUT` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/state` endpoint containing a value from the list of available states. ```typescript HTTP PUT "/signalk/v2/api/vessels/self/autopilots/{id}/state" {"value": "disabled"} ``` ### Getting the Autopilot State The current autopilot state can be retrieved by submitting an HTTP `GET` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/state` endpoint. ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots/{id}/state" ``` _Response:_ ```JSON { "value":"enabled", } ``` ### Setting the Autopilot Mode Autopilot mode can be set by submitting an HTTP `PUT` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/mode` endpoint containing a value from the list of available modes. ```typescript HTTP PUT "/signalk/v2/api/vessels/self/autopilots/{id}/mode" {"value": "gps"} ``` ### Getting the Autopilot Mode The current autopilot mode can be retrieved by submitting an HTTP `GET` request to the `mode` endpoint. ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots/{id}/mode" ``` _Response:_ ```JSON { "value":"gps", } ``` ### Setting the Target value Autopilot target value can be set by submitting an HTTP `PUT` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/target` endpoint containing the desired value. _Note: The value supplied should be a number within the valid range for the selected `mode`._ ```typescript // Set target to 129 degrees HTTP PUT "signalk/v2/api/vessels/self/autopilots/{id}/target" {"value": 129, "units": "deg"} // Set target to 0.349066 radians (20 degrees) HTTP PUT "signalk/v2/api/vessels/self/autopilots/{id}/target" {"value": 0.349066} ``` The target value can be adjusted a +/- value by submitting an HTTP `PUT` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/target/adjust` endpoint with the value to add to the current `target` value. ```typescript // Adjust target 2 degrees port HTTP PUT "signalk/v2/api/vessels/self/autopilots/{id}/target/adjust" {"value": -2, , "units": "deg"} // Adjust target 0.0349066 radians (2 degrees) starboard HTTP PUT "signalk/v2/api/vessels/self/autopilots/{id}/target/adjust" {"value": 0.0349066} ``` ### Getting the current Target value The current autopilot target value _(in radians)_ can be retrieved by submitting an HTTP `GET` request to the `target` endpoint. ```typescript HTTP GET "/signalk/v2/api/vessels/self/autopilots/{id}/target" ``` _Response:_ ```JSON { "value": 2.2345, } ``` ### Engaging / Disengaging the Autopilot #### Engaging the autopilot An autopilot can be engaged by [setting it to a speciifc `state`](#setting-the-state) but it can also be engaged more generically by submitting an HTTP `POST` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/engage` endpoint. ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/engage" ``` _Note: The resultant `state` into which the autopilot is placed will be determined by the **provider plugin** and the autopilot device it is communicating with._ #### Disengaging the autopilot An autopilot can be disengaged by [setting it to a speciifc `state`](#setting-the-state) but it can also be disengaged more generically by submitting an HTTP `POST` request to the `/signalk/v2/api/vessels/self/autopilots/{id}/disengage` endpoint. ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/disengage" ``` _Note: The resultant `state` into which the autopilot is placed will be determined by the **provider plugin** and the autopilot device it is communicating with._ ### Perform a Tack To send a command to the autopilot to perform a tack in the required direction, submit an HTTP `POST` request to `./autopilots/{id}/tack/{direction}` where _direction_ is either `port` or `starboard`. _Example: Tack to Port_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/tack/port" ``` _Example: Tack to Starboard_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/tack/starboard" ``` ### Perform a Gybe To send a command to the autopilot to perform a gybe in the required direction, submit an HTTP `POST` request to `/signalk/v2/api/vessels/self/autopilots/{id}/gybe/{direction}` where _direction_ is either `port` or `starboard`. _Example: Gybe to Port_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/gybe/port" ``` _Example: Gybe to Starboard_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/gybe/starboard" ``` ### Steer to current destination When a course has been set to a GPS position or waypoint (`APB`, `RMB`, etc data is available) submitting an HTTP `POST` request to `./autopilots/{id}/courseCurrentPoint` will send commands to the autopilot to: 1. Set the autopilot to the appropriate mode 2. Activate / engage the autopilot. _Example:_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/courseNextPoint" ``` ### Advancing Waypoint To send a command to the autopilot to advance to the next waypoint on a route, submit an HTTP `POST` request to `./autopilots/{id}/courseNextPoint`. _Example:_ ```typescript HTTP POST "/signalk/v2/api/vessels/self/autopilots/{id}/courseNextPoint" ``` ### Dodging Obstacles To address the various methods that the `dodge` function could be invoked on pilot devices, the API provides the following endpoints to provide the widest coverage possible: **To enter dodge mode at the current course** ```typescript POST "/signalk/v2/api/vessels/self/autopilots/{id}/dodge" ``` **To enter dodge mode and change course by 5 degrees starboard** ```typescript PUT "/signalk/v2/api/vessels/self/autopilots/{id}/dodge" {"value": 5, "units": "deg"} ``` **To enter dodge mode and change course by 5 degrees port** ```typescript PUT "/signalk/v2/api/vessels/self/autopilots/{id}/dodge" {"value": -5, "units": "deg"} ``` **To cancel dodge mode** ```typescript DELETE "/signalk/v2/api/vessels/self/autopilots/{id}/dodge" ``` ================================================ FILE: docs/develop/rest-api/conventions.md ================================================ --- title: API Conventions --- # Signal K REST API Conventions ## Overview This document outlines the conventions used when defining Signal K REST APIs. - Managing Configuration - Multiple Devices - Multiple Providers --- ### Managing Configuration APIs that provide configuration operations should provide operations under the `_config` path parameter. _Example: Set **apiOnly** mode in Course API_ ```shell HTTP POST "/signalk/v2/api/vessels/self/course/_config/apiOnly" ``` _Example: Clear **apiOnly** mode in Course API_ ```shell HTTP DELETE "/signalk/v2/api/vessels/self/course/_config/apiOnly" ``` --- ### Multiple Devices When an API supports the installation of multiple devices _(e.g. autopilots, radars, etc)_ it can designate one device to receive commands if a specific device / provider is not targeted. This done by using the `_default` parameter in the request path in place of the device identifier. _Example: Engage the default autopilot_ ```shell HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/_default/engage" ``` _Example: Engage a specific autopilot_ ```shell HTTP POST "/signalk/v2/api/vessels/self/steering/autopilots/raymarine-n2k/engage" ``` _Example: Retrieve the status of the default autopilot_ ```shell HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/_default" ``` _Example: Retrieve the status of a specific autopilot_ ```shell HTTP GET "/signalk/v2/api/vessels/self/steering/autopilots/raymarine-n2k" ``` --- ### Multiple Providers Some APIs support the use of one or more _providers_ to provide: - An aggregated set of data from varied sources _(e.g. resources)_ - The ability to interact with one or more services _(e.g. Weather providers)_ In these scenarios it is often required to perform operations to manage or target a provider. Provider specific operations can use either the: - `_providers` path parameter - `provider` query parameter. _Example: Retrieve the default provider servicing charts resources_ ```shell HTTP GET "/signalk/v2/api/resources/charts/_providers/_default" ``` _Example: Set the provider to handle creating new chart sources._ ```shell HTTP POST "/signalk/v2/api/resources/charts/_providers/_default/my-chart-plugin" ``` _Example: Create a new waypoint using the specified provider._ ```shell HTTP POST "/signalk/v2/api/resources/waypoints?provider=my-plugin-id" ``` --- ================================================ FILE: docs/develop/rest-api/course_api.md ================================================ --- title: Course API --- # Course API The _Course API_ provides common course operations under the path `/signalk/v2/api/vessels/self/navigation/course` ensuring that all related Signal K data model values are maintained and consistent. This provides a set of data that can be confidently used for _course calculations_ and _autopilot operation_. Additionally, the Course API persists course information on the server to ensure data is not lost in the event of a server restart. Client applications use `HTTP` requests (`PUT`, `GET`,`DELETE`) to perform operations and retrieve course data. The Course API also listens for destination information in the NMEA stream and will set / clear the destination accordingly _(e.g. NMEA0183 RMB sentence, NMEA2000 PGN 129284)_. See [Configuration](#Configuration) for more details. _Note: You can view the \_Course API_ OpenAPI definition in the Admin UI (Documentation => OpenApi).\_ --- ## Setting a Course The Course API provides endpoints for: 1. Navigate to a location _(lat, lon)_ 1. Navigate to a waypoint _(href to waypoint resource)_ 1. Follow a Route _(href to a route resource)_. ### 1. Navigate to a Location To navigate to a point submit a HTTP `PUT` request to `/signalk/v2/api/vessels/self/navigation/course/destination` and supply the latitude & longitude of the destination point. ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/destination' {"position": {"latitude": -60.5, "longitude": -166.7}} ``` ### 2. Navigate to a Waypoint To navigate to a point submit a HTTP `PUT` request to `/signalk/v2/api/vessels/self/navigation/course/destination` and supply a reference to a waypoint resource entry under `/resources/waypoints` ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/destination' {"href": "/resources/waypoints/5242d307-fbe8-4c65-9059-1f9df1ee126f"} ``` ### 3. Follow a Route To follow a route submit a HTTP `PUT` request to `/signalk/v2/api/vessels/self/navigation/course/activeRoute` and supply a reference to a route resource entry under `/resources/routes`. ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute' {"href": "/resources/routes/5242d307-fbe8-4c65-9059-1f9df1ee126f"} ``` Additional parameters can be set when following a route including: - Defining the point along the route to start at - The direction to follow the route (forward / reverse) _Example: Following a route in reverse direction:_ ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute' { "href": "/resources/routes/5242d307-fbe8-4c65-9059-1f9df1ee126f", "reverse": true } ``` #### Advancing along a Route As progress along a route is made, you can use the following endpoints to update the destination. To set the destination to the next point along the route: ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' ``` To advance the destination to a point `n` places beyond the current destination point, supply a positive number representing the number of points to advance: ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' {"value": 2} ``` _Sets destination to the point after the next in sequence._ To set the destination to the previous point along the route: ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' {"value": -1} ``` To set the destination to a point `n` places prior the current destination point, supply a negative number representing the number of points prior: ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/nextPoint' {"value": -2} ``` _Sets destination to the point two prior to the current destination._ To set the destination to a specific point along the route, supply the zero-based index of the point: _Example: 4th point along the route._ ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/pointIndex' {"value": 3} ``` _Value contains the 'zero-based' index of the point along the route (i.e. 0 = 1st point, 1 = 2nd point, etc.)_ To reverse the direction along the route: ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/activeRoute/reverse' ``` #### Delta Messages The Course API emits delta messages with the following paths when course information has been changed. _Note: Delta values reflect the relevant property of the Course Information data object as detailed in the [Retrieving Course Information](#retrieving-course-information) section._ - `navigation.course.startTime` - `navigation.course.targetArrivalTime` - `navigation.course.arrivalCircle` - `navigation.course.activeRoute` - `navigation.course.nextPoint` - `navigation.course.previousPoint` ## Retrieving Course Information Course information is retrieved by submitting a HTTP `GET` request to `/signalk/v2/api/vessels/self/navigation/course`. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course' ``` The contents of the response will reflect the operation used to set the current course. The `nextPoint` & `previousPoint` sections will always contain values but `activeRoute` will only contain values when a route is being followed. #### 1. Operation: Navigate to a location _(lat, lon)_ _Example response:_ ```JSON { "startTime": "2023-01-27T01:47:39.785Z", "targetArrivalTime": "2022-06-10T01:29:27.592Z", "arrivalCircle": 4000, "activeRoute": null, "nextPoint": { "type": "Location", "position": { "latitude": -34.92084502261776, "longitude": 131.54823303222656 } }, "previousPoint": { "type":"VesselPosition", "position": { "latitude": -34.82084502261776, "longitude": 131.04823303222656 } } } ``` #### 2. Operation: Navigate to a waypoint _(href to waypoint resource)_ _Example response:_ ```JSON { "startTime": "2023-01-27T01:47:39.785Z", "targetArrivalTime": "2022-06-10T01:29:27.592Z", "arrivalCircle": 4000, "activeRoute": null, "nextPoint": { "href": "/resources/waypoints/f24d72e4-e04b-47b1-920f-66b78e7b033e", "type": "Waypoint", "position": { "latitude": -34.92084502261776, "longitude": 131.54823303222656 } }, "previousPoint": { "type":"VesselPosition", "position": { "latitude": -34.82084502261776, "longitude": 131.04823303222656 } } } ``` #### 3. Operation: Follow a Route _(href to a route resource)_. _Example response:_ ```JSON { "startTime": "2023-01-27T01:47:39.785Z", "targetArrivalTime": "2022-06-10T01:29:27.592Z", "arrivalCircle": 1000, "activeRoute": { "href": "/resources/routes/e24d72e4-e04b-47b1-920f-66b78e7b0331", "pointIndex": 0, "pointTotal": 5, "reverse": false, "name": "my route", "waypoints": [ { "latitude": -34.92084502261776, "longitude": 131.54823303222656 }, { "latitude": -34.86621482446046, "longitude": 132.10166931152344, }, { "latitude": -34.6309479733581, "longitude": 132.23350524902344 }, { "latitude": -34.63546778783319, "longitude": 131.8867492675781 }, { "latitude": -34.71000915922492, "longitude": 131.82289123535156 } ] }, "nextPoint": { "type": "RoutePoint", "position": { "latitude": -34.92084502261776, "longitude": 131.54823303222656 } }, "previousPoint": { "type":"VesselPosition", "position": { "latitude": -34.82084502261776, "longitude": 131.04823303222656 } } } ``` ## Cancelling navigation To cancel the current course navigation and clear the course data. ```typescript HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course' ``` _Note: This operation will NOT change the destination information coming from the NMEA input stream! If the NMEA source device is still emitting destination data this will reappear as the current destination._ To ignore destination data from NMEA sources see [Configuration](#configuration) below. ## Configuration The default configuration of the Course API will accept destination information from both API requests and NMEA stream data sources. For NMEA sources, Course API monitors the the following Signal K paths populated by both the `nmea0183-to-signalk` and `n2k-to-signalk` plugins: - _navigation.courseRhumbline.nextPoint.position_ - _navigation.courseGreatCircle.nextPoint.position_ HTTP requests are prioritised over NMEA data sources, so making an API request will overwrite the destination information received from and NMEA source. Note: when the destination cleared using an API request, if the NMEA stream is emitting an active destination position, this will then be used by the Course API to populate course data. #### Ignoring NMEA Destination Information The Course API can be configured to ignore destination data in the NMEA stream by enabling `apiOnly` mode. In `apiOnly` mode destination information can only be set / cleared using HTTP API requests. - **`apiOnly` Mode = Off _(default)_** - Destination data is accepted from both _HTTP API_ and _NMEA_ sources - Setting a destination using the HTTP API will override the destination data received via NMEA - When clearing the destination using the HTTP API, if destination data is received via NMEA this will then be used as the active destination. - To clear destination sourced via NMEA, clear the destination on the source device. - **`apiOnly` Mode = On** - Course operations are only accepted via the HTTP API - NMEA stream data is ignored - Switching to `apiOnly` mode when an NMEA sourced destination is active will clear the destination. #### Retrieving Course API Configuration To retrieve the Course API configuration settings, submit a HTTP `GET` request to `/signalk/v2/api/vessels/self/navigation/course/_config`. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config' ``` _Example response:_ ```JSON { "apiOnly": false } ``` #### Enable / Disable `apiOnly` mode To enable `apiOnly` mode, submit a HTTP `POST` request to `/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly`. _Enable apiOnly mode:_ ```typescript HTTP POST 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly' ``` To disable `apiOnly` mode, submit a HTTP `DELETE` request to `/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly`. _Disable apiOnly mode:_ ```typescript HTTP DELETE 'http://hostname:3000/signalk/v2/api/vessels/self/navigation/course/_config/apiOnly' ``` ## Course Calculations Whilst not performing course calculations, the _Course API_ defines the paths to accommodate the values calculated during course navigation. Click [here](../plugins/course_calculations.md) for details. ================================================ FILE: docs/develop/rest-api/history_api.md ================================================ --- title: History API --- # History API The _History API_ provides access to historical data, typically stored in a database. The actual storage backend is not defined by this API and can be implemented in various ways, typically as a plugin like [signalk-parquet](https://www.npmjs.com/package/signalk-parquet) and [signalk-to-influxdb2](https://www.npmjs.com/package/signalk-to-influxdb2). The most common use case for the API is to show graphs of past values. The API is available under the path `/signalk/v2/api/history`. _Note: You can view the \_History API_ OpenAPI definition in the Admin UI (Documentation => OpenApi).\_ --- ## Time Range Parameters The time range for queries can be defined as a combination of **from**, **to** and **duration** parameters. - **from**: Start of the time range, inclusive as ISO 8601 timestamp (e.g. `2018-03-20T09:13:28Z`) - **to**: End of the time range, inclusive. Defaults to 'now' if omitted. - **duration**: Duration of the time range in milliseconds (integer) or as an ISO8601 Duration string (e.g. `PT15M`). Can be specified with either 'from' or 'to'. If they are both omitted is relative to 'now'. ## Retrieving Historical Data To retrieve historical data series for specific paths, submit a HTTP `GET` request to `/signalk/v2/api/history/values`. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/history/values?paths=navigation.speedOverGround&duration=PT1H' ``` ### Query Parameters | Parameter | Description | Required | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | | `paths` | Comma separated list of Signal K paths whose data should be retrieved. Optional aggregation methods for each path as postfix separated by a colon. Aggregation methods: 'average' \| 'min' \| 'max' \| 'first' \| 'last' \| 'mid' \| 'middle_index'. Example: `navigation.speedOverGround,navigation.speedThroughWater:max` | Yes | | `context` | Signal K context that the data is about, defaults to 'vessels.self'. Example: `vessels.urn:mrn:imo:mmsi:123456789` | No | | `resolution` | Length of data sample time window in milliseconds or as a time expression ('1s', '1m', '1h', '1d'). If resolution is not specified the server should provide data in a reasonable time resolution, depending on the time range in the request. | No | | `from` | Start of the time range | No | | `to` | End of the time range | No | | `duration` | Duration of the time range | No | | `provider` | Plugin id of the history provider to direct the request to. If not specified, the default provider is used. See [Providers](#providers). | No | ### Response Format The response contains the requested data series with header information. ```JSON { "context": "vessels.urn:mrn:imo:mmsi:123456789", "range": { "from": "2018-03-20T09:12:28Z", "to": "2018-03-20T09:13:28Z" }, "values": [ { "path": "navigation.speedOverGround", "method": "average" } ], "data": [ ["2023-11-09T02:45:38.160Z", 13.2], ["2023-11-09T02:45:39.160Z", 13.4] ] } ``` The `data` array contains arrays where the first element is the timestamp in ISO 8601 format, and subsequent elements correspond to the values for the requested paths in order. Missing data for a path is returned as `null`. ## Listing Available Contexts To get a list of contexts that have some historical data available for a specified time range, submit a HTTP `GET` request to `/signalk/v2/api/history/contexts`. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/history/contexts?duration=P1D' ``` ### Response Format Returns an array of context strings. ```JSON [ "vessels.urn:mrn:imo:mmsi:123456789", "vessels.urn:mrn:imo:mmsi:987654321" ] ``` ## Listing Available Paths To get a list of paths that have some historical data available for a specified time range, submit a HTTP `GET` request to `/signalk/v2/api/history/paths`. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/history/paths?duration=P1D' ``` ### Response Format Returns an array of path strings. ```JSON [ "navigation.speedOverGround", "navigation.courseOverGroundTrue" ] ``` --- ## Providers The History API supports the registration of multiple history provider plugins. The first plugin registered is set as the _default_ provider and all requests will be directed to it. Requests can be directed to a specific provider by using the `provider` parameter in the request with the _id_ of the provider plugin. _Example:_ ```javascript GET "/signalk/v2/api/history/values?paths=navigation.speedOverGround&duration=PT1H&provider=signalk-to-influxdb2" ``` > [!NOTE] Any installed history provider can be set as the default. _See [Setting the Default Provider](#setting-the-default-provider)_ ### Listing Available Providers To retrieve a list of installed history provider plugins, submit an HTTP `GET` request to `/signalk/v2/api/history/_providers`. The response will be an object containing all the registered history providers, keyed by their plugin id, indicating whether each is assigned as the _default_. _Example:_ ```typescript HTTP GET "/signalk/v2/api/history/_providers" ``` _Response:_ ```JSON { "signalk-to-influxdb2": { "isDefault": true }, "signalk-parquet": { "isDefault": false } } ``` ### Getting the Default Provider To get the id of the _default_ provider, submit an HTTP `GET` request to `/signalk/v2/api/history/_providers/_default`. _Example:_ ```typescript HTTP GET "/signalk/v2/api/history/_providers/_default" ``` _Response:_ ```JSON { "id": "signalk-to-influxdb2" } ``` ### Setting the Default Provider To set / change the history provider that requests will be directed to, submit an HTTP `POST` request to `/signalk/v2/api/history/_providers/_default/{id}` where `{id}` is the identifier of the history provider to use as the _default_. _Example:_ ```typescript HTTP POST "/signalk/v2/api/history/_providers/_default/signalk-parquet" ``` ================================================ FILE: docs/develop/rest-api/notifications_api.md ================================================ --- title: Notifications API --- # Notifications API The Notifications API enables the raising, actioning and centralised management of Signal K notifications and their associated alarms. ## Overview Notifications are a special type Signal K update delta that convey the occurrence of an event or change in condition. They contain a `path` value that starts with the text `notifications` and a payload with specific attributes to indicate: - The severity of the event / condition (`state`) - How the event / condition should be indicated to the operator (`method`) - What actions can be / have been taken (`status`) The **Notifications API** introduces the following components into the Signal K server's delta processing chain: 1. Notification Manager: Provides centralised management for all notifications including the emission of notification deltas 2. Input Handler: Inspects all incoming deltas and routes notification messages _(i.e. path starts with `notifications`)_ to the Notification Manager. 3. Notification API: REST endpoints for raising and actioning notifications and their associated alarm. ### Terminology > The Signal K specification uses the terms `notification` and `alarm` interchangably, whilst Signal K Server assigns notification deltas originating from NMEA2000 alarm PGNs with attributes with the term `alert`. For consistency and clarity this document will use the the following terminology: - `notification` - A Signal K update delta message with a path starting with the text _notifications._ - `alarm` - The communication of the event / condition to the operator. ### Initial Release The initial release of the Notifications API implements core functionality to attribute Signal K notifications to allow them to be actioned and managed, regardless of their source. It does this by: - Placing notifications into their own `update` in the delta message - Assigning them a unique identifier - Adding a `status` property to the payload - Making available HTTP endpoints at `/signalk/v2/api/notifications` to perform actions. > **Note:** Actions are only available for notifications containing a payload containing `state` and `method` properties. ### Target State Subsequent releases of Signal K server will contain enhancements to the **Notification API** to implement the remaining functionality including: - Creating and clearing notifications - Managing Alarm lifecycle - Raising specified alarms i.e. MOB - Plugin interface ## Notification Payload The Notification API adds the following attributes to the notification payload: - `id` - Unique identifier for use when taking action. - `status` - An object detailing the actions that can be and have been taken. _Example_ ```json { "state": "...", "method": [..], "message": "...", "id": "a987be59-d26f-46db-afeb-83987b837a8f", "status": { "silenced": true, "acknowledged": false, "canSilence": true, "canAcknowledge": true, "canClear": true } } ``` ### Notification Status The `status` attribute is added to all notifications that have a payload containing `state` and `method` attributes. The following status properties indicate the actions that **CAN be taken**. Their values are determined by the notification's `state` attribute: - `canSilence` - indicates whether the Alarm associated with this notification can be silenced - `canAcknowledge` - indicates whether the Alarm associated with this notification can be acknowledged - `canClear` - Indicates that the associated Alarm can be cleared (triggering condition has been resolved). _The value is `false` when the notification is not originated by the Notifcations API._ The remaining properties indicate the actions that **HAVE been taken**: - `silenced` - `true` when silence action has been taken - `acknowledged` - `true` when acknowledge action has been taken ## Taking Action To take action on the alarm associated with a notification, send an HTTP POST request to `/signalk/v2/api/notifications/{notificationId}/{action}`. ### Silencing an Alarm > Note: The silence action is only available for alarms associated with notifications having `status.canSilence = true`. To silence the alarm send an HTTP POST request to `/signalk/v2/api/notifications/{notificationId}/silence`. The result of a successful silence request is that the: - `sound` value is removed from the `method` attribute - `status.silenced` is set to `true` If the silence action is requested when the `status.canSilence` property is `false`, the alarm will not be silenced and an ERROR response is returned to the requestor. > **Note:** Notifications with `state = emergency` cannot be silenced regardless of the value of `status.canSilence`. _Example: Notification payload prior to `silence` action request._ ```JSON { "message": "Engine temperature is high!", "method": ["sound", "visual"], "state": "alert", "id": "a987be59-d26f-46db-afeb-83987b837a8f", "status": { "silenced": false, "acknowledged": false, "canSilence": true, "canAcknowledge": true, "canClear": true } } ``` _Silence action request_ ```typescript HTTP POST "/signalk/v2/api/notifications/a987be59-d26f-46db-afeb-83987b837a8f/silence" ``` _Notification: post `silence` request_ ```JSON { "message": "Engine temperature is high!", "method": ["visual"], "state": "alert", "id": "a987be59-d26f-46db-afeb-83987b837a8f", "status": { "silenced": true, "acknowledged": false, "canSilence": true, "canAcknow;edge": true, "canClear": true } } ``` ### Acknowledging an Alarm > Note: The acknowledge action is only available for alarms associated with notifications having `status.canAcknowledge` = `true`. To acknowledge the alarm send an HTTP POST request to `/signalk/v2/api/notifications/{notificationId}/acknowledge`. The result of a successful acknowledge request is that the: - Both `sound` & `visual` values are removed from the `method` attribute - `status.acknowledged` is set to `true` If the acknowledge action is requested when the `status.canAcknowledge` property is `false`, the alarm will not be acknowledged and an ERROR response is returned to the requestor. > **Note:** Notifications with `state = emergency` will only have the `sound` value removed from `method`. _Example: Notification prior to `acknowledge` request._ ```JSON { "message": "Engine temperature is high!", "method": ["sound", "visual"], "state": "alert", "id": "a987be59-d26f-46db-afeb-83987b837a8f", "status": { "silenced": true, "acknowledged": false, "canSilence": true, "canAcknow;edge": true, "canClear": true } } ``` _Acknowledge action request_ ```typescript HTTP POST "/signalk/v2/api/notifications/a987be59-d26f-46db-afeb-83987b837a8f/acknowledge" ``` _Notification: post `acknowledge` request_ ```JSON { "message": "Engine temperature is high!", "method": [], "state": "alert", "id": "a987be59-d26f-46db-afeb-83987b837a8f", "status": { "silenced": true, "acknowledged": true, "canSilence": true, "canAcknow;edge": true, "canClear": true } } ``` ## NMEA2000 Alert Handling NMEA2000 alarm PGNs are processed by `n2k-signalk` which generates Signal K notification deltas as follows: - Path value starting with `notifications.nmea.*` - PGN fields mapped to the `state`, `method` and `message` attributes - Additional attributes capturing PGN field values added to the notification payload. The **Notification API** uses these additional PGN attributes to populate the notification `status` to align the available actions and any action taken. _Example: Signal K notification from NMEA2000 Alarm PGN_ ```json { "path": "notifications.nmea.alarm.navigational.20.8196" // "value": { "state": "alarm", "method": [ "visual", "sound" ], "message": "Highwater", "alertType": "Alarm", "alertCategory": "Navigational", "alertSystem": 20, "alertId": 8196, "dataSourceNetworkIDNAME": 1240095849160158000, "dataSourceInstance": 215, "dataSourceIndex-Source": 1, "occurrence": 2, "temporarySilenceStatus": "No", "acknowledgeStatus": "No", "escalationStatus": "No", "temporarySilenceSupport": "No", "acknowledgeSupport": "Yes", "escalationSupport": "No", "acknowledgeSourceNetworkIDNAME": 1233993293343261200, "triggerCondition": "Auto", "thresholdStatus": "Threshold Exceeded", "alertPriority": 0, "alertState": "Active" } } ``` ### NMEA2000 -> Signal K `state` Mapping NMEA2000 alarm states are mapped to Signal K notification `state` as follows: | NMEA2000 State | Signal K State | Description | | --------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Emergency Alarm | `emergency` | A life-threatening condition | | Alarm | `alarm` | Immediate action is required to prevent loss of life or equipment damage | | Warning | `warn` | Indicates a condition that requires immediate attention but not immediate action | | Caution | `alert` | Indicates a safe or normal condition which is brought to the operators attention to impart information for routine action purposes | | -- | `normal` | Indicates normal operation. | | -- | `nominal` | This is can be used to indicate value has entered a range within the normal zone | _Reference: [Signal K Specification](https://signalk.org/specification/1.7.0/doc/data_model_metadata.html?highlight=emergency#metadata-for-a-data-value)_ ### NMEA2000 alarm `method` Mapping The `n2k-signalk` plugin will set a notification's `method = []` when the NMEA2000 `acknowledgeStatus` OR `temporarySilenceStatus` attributes are set to **"Yes"**. The **Notifications API** will re-write the `method` attribute value, as outlined above in [Silencing a Notification](#silencing-a-notification) and [Acknowledging a Notification](#acknowledging-a-notification), to ensure alignment across all notifications regardless of source. ================================================ FILE: docs/develop/rest-api/plugin_api.md ================================================ --- title: Plugin API --- # Plugin configuration HTTP API ## `GET /plugins/` Get a list of installed plugins and their configuration data. ## `GET /plugins/` Get information from an installed plugin. Example result: ```json { "enabled": false, "id": "marinetrafficreporter", "name": "Marine Traffic Reporter" } ``` ## `POST /plugins//configure` Save configuration data for a plugin. Stops and starts the plugin as a side effect. ================================================ FILE: docs/develop/rest-api/proposed/README.md ================================================ --- title: Proposed APIs children: - anchor_api.md --- # PROPOSED APIs The following APIs have been identified for future development: | Proposed API | Description | Endpoint | | --------------------------- | ----------------------------------------------------------------------- | -------------------------------- | | _[`Anchor`](anchor_api.md)_ | Provide endpoints to perform operations and facilitate an anchor alarm. | `vessels/self/navigation/anchor` | --- ================================================ FILE: docs/develop/rest-api/proposed/anchor_api.md ================================================ --- title: Anchor API --- # Anchor API #### (Proposed) _Note: The definition of this API is currently under development and the information provided here is likely to change._ The Signal K server Anchor API will define endpoints that can be implemented by plugins for the purposes of implementing and and operating an anchor alarm. A plugin that implements this API must ensure that all endpoints and operations comply with the definition to ensure applications making requests receive reliable and consistent results. The following HTTP requests are proposed: POST `/navigation/anchor/drop` (Commence lowering of anchor) POST `/navigation/anchor/radius` { value: number } (Set the radius of the alarm area) POST `/navigation/anchor/reposition` { rodeLength: number, anchorDepth: number } (Calculate anchor position) POST `/navigation/anchor/raise` (Commence raising the anchor) ================================================ FILE: docs/develop/rest-api/radar_api.md ================================================ --- title: Radar API --- # Radar API The Signal K server Radar API provides a unified interface for viewing and controlling marine radar equipment from any manufacturer. The API is **(web)app-friendly**: clients can build dynamic UIs that automatically adapt to any radar's capabilities without hardcoding support for specific brands or models. This is version v3.1.0 of the API. The version will use semver for version updates. Radar functionality is provided by "provider plugins" that handle the interaction with radar hardware and stream spoke data to connected clients. Requests to the Radar API are made to HTTP REST endpoints rooted at `/signalk/v2/api/vessels/self/radars` or the Signal K websocket stream at `/signalk/v1/stream`. Like `signalk-server` vis-a-vis the Signal K specification there is a reference implementation for this API, which may very well remain the only implementation of the server side of the API, at https://github.com/MarineYachtRadar/mayara-server. However, like Signal K itself, there is no reason it needs to remain the only implementation. In particular it would be ultra cool if some manufacturer of marine hardware would implement this API -- even though this is very unlikely. ## Design Philosophy: Capabilities-Driven API This API uses a **self-describing schema** pattern that benefits both radar provider developers and client/chartplotter developers. ### For Client/Chartplotter Developers Build a **single, adaptive UI** that works with any radar—now and in the future—without hardcoding brand-specific logic. **How it works:** 1. **Fetch capabilities once** when a radar connects — this tells you what the radar can do 2. **Generate UI widgets from the schema:** - `dataType: "number"` → Slider with min/max/step - `dataType: "enum"` with `descriptions` → Dropdown or button group - `dataType: "string"` → Text input field - `dataType: "button"` → Action button - `dataType: "sector"` → Angle range selector (start/end angles) - `dataType: "zone"` → Guard zone editor (angles + distances) - `dataType: "rect"` → Rectangular exclusion zone (two corners + width) - `isReadOnly: true` → Display-only label 3. **Subscribe to updates for current values** — the schema tells you what to expect 4. **Connect to websocket for spoke data** - receive the binary spoke data stream **Example: Rendering a Gain Control** ```shell $ curl -s http://10.56.0.1:6502/signalk/v2/api/vessels/self/radars/nav1034A/capabilities | jq '.controls.gain' { "category": "base", "dataType": "number", "description": "How sensitive the radar is to returning echoes", "hasAuto": true, "hasAutoAdjustable": false, "id": 4, "maxValue": 100.0, "minValue": 0.0, "name": "Gain", "stepValue": 1.0 } $ curl -s http://10.56.0.1:6502/signalk/v2/api/vessels/self/radars/nav1034A/controls/gain {"auto":false,"value":58} ``` Your UI renders: - Mode toggle: `[Auto] [Manual]` - Value slider: `0 ----[58]---- 100` (disabled or hidden when mode=auto) Whether it's a Furuno DRS4D-NXT with 20, a Navico HALO with 40 controls or a basic radar with 5 controls, the same client code handles both. ### For Radar Provider Developers (Plugin Authors) Different manufacturers have vastly different hardware capabilities, control sets, value ranges, and operating modes. Instead of clients hardcoding knowledge about each model, your provider plugin **declares** what the radar can do: 1. **Capabilities** — hardware capabilities (Doppler, dual-range, no-transmit zones, supported ranges) 2. **Controls** — schema for each control (type, valid values, modes, read-only status) ## Control Categories | Category | Description | Examples | | -------------- | ---------------------------- | -------------------------------------------------- | | `base` | Available on all radars | power, range, gain, sea, rain | | `targets` | Target tracking settings | targetExpansion, targetTrails | | `guardZones` | Guard zone configuration | guardZone1, guardZone2 | | `trails` | Trail display settings | trailsTime, clearTrails | | `advanced` | Model-specific features | dopplerMode, beamSharpening, interferenceRejection | | `installation` | Setup/configuration settings | antennaHeight, bearingAlignment, noTransmitSector1 | | `info` | Read-only information | serialNumber, firmwareVersion, transmitTime | Read-only information (serialNumber, firmwareVersion, operatingHours) is exposed as controls with `isReadOnly: true`. Some controls are **dynamically** read-only when a particular mode is set. This is handled with an optional `allowed: ` field in the control value. Some further considerations as how to show controls: - Within each category, all controls have a numeric `id` field which may be used for ordering. - The `advanced` and especially the `installation` categories could be shown in a different panel. - In particular the `installation` controls are typically configured once. - The `power` and `range` controls are used often and should be easy to be controlled. - The `gain`, `sea` and `rain` controls are usually represented graphically on a PPI window. ## API Overview ```text /signalk/v2/api/vessels/self/radars ├── GET → List all active radars ├── /interfaces │ └── GET → List network interfaces and listener status └── /{radar_id} ├── /capabilities GET → Get radar capabilities and control definitions ├── /controls │ ├── GET → Get all control values │ └── /{control_id} │ ├── GET → Get single control value │ └── PUT → Set single control value ├── /spokes → WebSocket (spoke data in binary format) └── /targets ├── GET → List tracked targets ├── POST → Acquire target manually └── /{target_id} └── DELETE → Cancel target tracking /signalk/v1/stream → WebSocket (control values and targets for all radars) ``` ## REST API ### Listing All Radars Retrieve all available radars with their current info: ```text HTTP GET "/signalk/v2/api/vessels/self/radars" ``` _Response:_ ```json { "nav1034A": { "brand": "Navico", "model": "HALO", "name": "HALO 034A", "radarIpAddress": "192.168.1.50", "spokeDataUrl": "ws://192.168.1.100:8080/signalk/v2/api/vessels/self/radars/nav1034A/spokes", "streamUrl": "ws://192.168.1.100:8080/signalk/v1/stream" }, "nav1034B": { "brand": "Navico", "model": "HALO", "name": "HALO 034B", "radarIpAddress": "192.168.1.50", "spokeDataUrl": "ws://192.168.1.100:8080/signalk/v2/api/vessels/self/radars/nav1034B/spokes", "streamUrl": "ws://192.168.1.100:8080/signalk/v1/stream" } } ``` ### Network Interfaces Check which network interfaces are available and which radar brands are listening: ```text HTTP GET "/signalk/v2/api/vessels/self/radars/interfaces" ``` _Response:_ ```json { "brands": ["Navico", "Furuno", "Raymarine"], "interfaces": { "en0": { "status": "Ok", "ip": "192.168.1.100", "netmask": "255.255.255.0", "listeners": { "Navico": "Active", "Furuno": "No match for 172.31.255.255", "Raymarine": "Listening" } }, "en1": { "status": "WirelessIgnored" } } } ``` This endpoint is useful for diagnosing network configuration issues when radars are not being detected. ### Getting Radar Capabilities The capability manifest describes everything a radar can do. Clients should fetch this at the beginning of a session. The contents do not change during radar operation. ```text HTTP GET "/signalk/v2/api/vessels/self/radars/{radar_id}/capabilities" ``` _Response:_ ```json { "maxRange": 74080, "minRange": 50, "supportedRanges": [ 50, 75, 100, 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 6000, 8000, 12000, 16000, 24000, 36000, 48000, 64000, 74080 ], "spokesPerRevolution": 2048, "maxSpokeLength": 1024, "pixelValues": 16, "hasDoppler": true, "hasDualRadar": false, "hasDualRange": true, "hasSparseSpokes": false, "noTransmitSectors": 2, "controls": { "gain": { "id": 4, "name": "Gain", "description": "How sensitive the radar is to returning echoes", "category": "base", "dataType": "number", "minValue": 0.0, "maxValue": 100.0, "stepValue": 1.0, "hasAuto": true, "hasAutoAdjustable": false } }, "legend": { "lowReturn": 1, "mediumReturn": 8, "strongReturn": 13, "targetBorder": 17, "dopplerApproaching": 18, "dopplerReceding": 19, "historyStart": 20, "pixelColors": 16, "pixels": [ { "type": "normal", "color": { "r": 0, "g": 0, "b": 0, "a": 0 } }, { "type": "normal", "color": { "r": 0, "g": 0, "b": 51, "a": 255 } } ] } } ``` Capability fields: 1. `hasDoppler` - if true, the radar can detect boats or objects approaching or receding and emits separate pixel colors for these. 2. `hasDualRadar` - if true, the physical radome reports itself as two independent radars that can be set to different ranges and modes. Currently only Navico 4G and HALO support this. 3. `hasDualRange` - mutually exclusive with `hasDualRadar`, indicates a more limited form of supporting two ranges with one device. 4. `minRange` and `maxRange` - define what ranges the radar supports (in meters). 5. `supportedRanges` - list of all discrete range values the radar supports (in meters). 6. `maxSpokeLength` and `spokesPerRevolution` - define how many pixels the radar produces each revolution. 7. `noTransmitSectors` - how many sectors the radar can stop transmitting to avoid obstacles like masts. 8. `pixelValues` - number of distinct pixel intensity values. 9. `hasSparseSpokes` - if true, the radar produces fewer spokes per revolution than `spokesPerRevolution` indicates (see [Spoke skipping](#spoke-skipping)). ### Legend All spokes are sent with one byte per pixel. The legend explains what each byte value represents. ```json { "lowReturn": 1, "mediumReturn": 8, "strongReturn": 13, "dopplerApproaching": 18, "dopplerReceding": 19, "historyStart": 20, "pixelColors": 16, "pixels": [ { "type": "normal", "color": "#00000000" }, { "type": "normal", "color": "#0000ffff" }, { "type": "dopplerApproaching", "color": "#ff00ffff" }, { "type": "dopplerReceding", "color": "#00ff00ff" }, { "type": "history", "color": "#454545ff" } ] } ``` The `lowReturn`, `mediumReturn`, and `strongReturn` indicate offsets in the array, typically used for smoothing algorithms. If the radar doesn't implement Doppler, the `dopplerApproaching` and `dopplerReceding` fields will be null. If the provider doesn't implement target trails, `historyStart` will be null. ### Dual range/radar There are two different ways that radars handle "dual" ranges. Navico radars implement this by acting as if both radars are full independent, to the point where both radars use different ports and IP addresses. They can be seen to be dependent in that if you change some controls they also change on the other radar. The NoTransmitZones are examples of such controls. These radars therefore also show up as two radars in the API. As long as clients listen to updates to controls, which they should do anyway to be able to function in a setting where there is for instance a MFD device, they can assume that all controls can be set. Furuno radars do this in a way where the second range shares as many control settings as possible. Currently there is no support for Furuno dual range yet and its impact on the API is unknown. ### Controls The `controls` object in capabilities lists all controls the radar supports. Control data types: | dataType | Description | | -------- | ---------------------------------------- | | number | Numeric value with min/max/step | | enum | Discrete set of values with descriptions | | string | Text value | | button | Action trigger (no value) | | sector | Angle range (start/end) | | zone | Guard zone (angles + distances) | | rect | Rectangular exclusion zone | 1. **number** ```json { "id": 47, "name": "Transmit time", "description": "How long the radar has been transmitting over its lifetime", "category": "info", "dataType": "number", "isReadOnly": true, "minValue": 0.0, "maxValue": 3599996400.0, "stepValue": 3600.0, "units": "s" } ``` The `units` field indicates the unit of measurement for the control value. A conforming server implementation sends only SI units to clients: | Category | SI Unit | Abbreviation | | ---------------- | ------------------ | ------------ | | Distance | Meters | `m` | | Speed | Meters per second | `m/s` | | Angle | Radians | `rad` | | Rotational speed | Radians per second | `rad/s` | | Duration | Seconds | `s` | Note how in the above example the server has converted a value in hours (3600 seconds) to seconds to conform to the above, but the client can convert the value back to hours for representation to a human. A conforming API server will allow the following units to be specified when receiving values from a client: | Category | Unit | Abbreviation | | ---------------- | ------------------ | ------------ | | Distance | Meters | `m` | | Distance | Kilometers | `km` | | Distance | Nautical miles | `nm` | | Speed | Meters per second | `m/s` | | Speed | Knots | `kn` | | Angle | Radians | `rad` | | Angle | Degrees | `deg` | | Rotational speed | Radians per second | `rad/s` | | Rotational speed | Rotations/minute | `rpm` | | Duration | Seconds | `s` | | Duration | Minutes | `min` | | Duration | Hours | `h` | 2. **enum** ```json { "id": 0, "name": "Power", "description": "Radar operational state", "category": "base", "dataType": "enum", "minValue": 0.0, "maxValue": 3.0, "stepValue": 1.0, "descriptions": { "0": "Off", "1": "Standby", "2": "Transmit", "3": "Preparing" }, "validValues": [1, 2] } ``` The `validValues` array indicates which values can be set by clients. The `power` control guarantees that at least these values can be set across all radars: 1 (Standby) and 2 (Transmit). 3. **string** ```json { "id": 53, "name": "Custom name", "description": "User defined name for the radar", "category": "advanced", "dataType": "string" } ``` 4. **button** A button triggers an action without needing a value: ```json { "id": 15, "name": "Clear trails", "description": "Clear target trails", "category": "trails", "dataType": "button" } ``` 5. **sector** ```json { "id": 35, "name": "No Transmit sector", "description": "First no-transmit sector", "category": "installation", "dataType": "sector", "hasEnabled": true, "minValue": -3.141592653589793, "maxValue": 3.141592653589793, "stepValue": 0.0017453292519943296, "units": "rad" } ``` A sector defines a start and end angle from -π to +π radians, plus an enabled flag. The value for start is transmitted in `value` and the end in `endValue`. ```shell $ curl -s http://localhost:6502/signalk/v2/api/vessels/self/radars/nav1034A/controls/noTransmitSector1 {"enabled":true,"value":-1.5533,"endValue":-1.2217} ``` 6. **zone** ```json { "id": 16, "name": "Guard zone", "description": "First guard zone for target detection", "category": "guardZones", "dataType": "zone", "hasEnabled": true, "minValue": -3.141592653589793, "maxValue": 3.141592653589793, "maxDistance": 100000.0, "units": "rad" } ``` A zone defines five attributes: start angle, end angle, start distance, end distance, and enabled. ```shell $ curl -s http://localhost:6502/signalk/v2/api/vessels/self/radars/nav1034A/controls/guardZone1 {"enabled":true,"value":-0.5585,"endValue":1.7104,"startDistance":100.0,"endDistance":232.0} ``` 7. **rect** ```json { "id": 60, "name": "Exclusion zone", "description": "Rectangular exclusion zone", "category": "guardZones", "dataType": "rect", "hasEnabled": true, "maxValue": 100000.0 } ``` A rect defines a rectangular zone using two corners and a perpendicular width. The corners (x1, y1) and (x2, y2) define one edge of the rectangle in meters relative to the radar position (positive X is starboard, positive Y is ahead). The width extends perpendicular to this edge. ```shell $ curl -s http://localhost:6502/signalk/v2/api/vessels/self/radars/nav1034A/controls/exclusionZone1 {"enabled":true,"x1":-50.0,"y1":100.0,"x2":50.0,"y2":100.0,"width":200.0} ``` ## Radar Control Controlling the radar can be done via HTTP REST requests or via the stream websocket. ### Getting All Control Values ```text HTTP GET "/signalk/v2/api/vessels/self/radars/{radar_id}/controls" ``` _Response:_ ```json { "gain": { "auto": false, "value": 50 }, "sea": { "auto": true, "autoValue": 25, "value": 30 }, "range": { "value": 3000 } } ``` ### Getting a Single Control Value ```text HTTP GET "/signalk/v2/api/vessels/self/radars/{radar_id}/controls/{control_id}" ``` _Response:_ ```json { "auto": false, "value": 50 } ``` ### Setting a Control Value ```text HTTP PUT "/signalk/v2/api/vessels/self/radars/{radar_id}/controls/{control_id}" ``` **Simple numeric control:** ```json { "value": 75 } ``` **Control with auto mode:** ```json { "auto": false, "value": 75 } ``` or just change auto mode: ```json { "auto": true } ``` **Control with auto adjustment (e.g., Sea on HALO):** When in auto mode, some controls accept an adjustment value: ```json { "auto": true, "autoValue": -20 } ``` **Sector control:** ```json { "enabled": true, "value": -1.5533, "endValue": -1.2217 } ``` **Zone control:** ```json { "enabled": true, "value": -0.5585, "endValue": 1.7104, "startDistance": 100.0, "endDistance": 500.0 } ``` **Button control:** For buttons, send an empty body or `{}` - the PUT request itself triggers the action. ### Control Value Fields Control values contain different fields depending on the control's `dataType` (defined in the capability schema). **Common fields**: | Field | Description | | ----------- | ----------------------------------------------------------------- | | `value` | The control value (numeric or string) (if dataType is not `rect`) | | `auto` | Whether automatic mode is enabled (if `hasAuto` is true) | | `autoValue` | Adjustment when auto=true (if `hasAutoAdjustable` is true) | | `timestamp` | ISO 8601 timestamp when value was last changed | **dataType-specific fields**: | Field | dataType | Description | | --------------- | ------------------ | ------------------------------------- | | `enabled` | sector, zone, rect | Whether the control is enabled | | `endValue` | sector, zone | End angle (radians) | | `startDistance` | zone | Inner radius (meters) | | `endDistance` | zone | Outer radius (meters) | | `x1` | rect | First corner X (meters, starboard +) | | `y1` | rect | First corner Y (meters, ahead +) | | `x2` | rect | Second corner X (meters, starboard +) | | `y2` | rect | Second corner Y (meters, ahead +) | | `width` | rect | Perpendicular width (meters) | ## ARPA Target Tracking The Radar API defines ARPA (Automatic Radar Plotting Aid) target tracking with CPA/TCPA calculations and SignalK notification integration. `mayara-server` fully supports both ARPA and MARPA, but this is an optional part of the API. When a server does not support it it shall return HTTP status 501. If the radar is a dual-radar device then `mayara-server` has a CLI option `--merge-targets`, when this is used targets will be shared between both ranges and move from one radar to another. ### Listing Tracked Targets ```text HTTP GET "/signalk/v2/api/vessels/self/radars/{id}/targets" ``` _Response:_ ```json [ { "id": 1, "status": "tracking", "position": { "bearing": 0.789, "distance": 1852, "latitude": 52.3702, "longitude": 4.8952 }, "motion": { "course": 3.14159, "speed": 3.34 }, "danger": { "cpa": 150, "tcpa": 324 }, "acquisition": "auto", "sourceZone": 1, "firstSeen": "2025-01-15T10:25:00Z", "lastSeen": "2025-01-15T10:30:00Z" } ] ``` **Units:** All distances are in meters. All angles (bearing, course) are in radians [0, 2π). Speed is in m/s. Time values (tcpa) are in seconds. **Optional fields:** Sub-structures are omitted when data is not yet known or not applicable: - `motion`: Omitted when motion is not yet computed (target still acquiring). Present with `speed: 0` and `course: 0` for confirmed stationary targets (buoys, anchored vessels). - `danger`: Omitted when vessels are diverging (no CPA exists) or own-ship motion unavailable - `position.latitude`/`longitude`: Omitted when radar position is unavailable - `sourceZone`: Omitted for manually acquired targets or Doppler-detected targets ### Manual Target Acquisition ```text HTTP POST "/signalk/v2/api/vessels/self/radars/{id}/targets" ``` _Request body:_ ```json { "bearing": 0.785, "distance": 2000 } ``` ### Cancel Target Tracking ```text HTTP DELETE "/signalk/v2/api/vessels/self/radars/{id}/targets/{targetId}" ``` ## Streaming API (WebSocket) There are two types of websocket: 1. Control Stream: Signal-K-formatted JSON messages containing control information to and from radars, as well as targets. 2. Spoke Data Stream: High-volume radar spoke data in binary format (up to 1 MB/s). ## Control Stream The JSON data websocket provides real-time control value updates for all radars via the standard Signal K stream. The URI is found in the radar response as `streamUrl` or can be constructed as: ```text ws://{host}:{port}/signalk/v1/stream ``` This websocket endpoint works identical to a Signal K stream, as documented in https://signalk.org/specification/1.5.0/doc/streaming_api.html In short: - By default you are described to all paths - Query parameters `subscribe=none` can be used to start without any subscriptions and `sendCachedValues=false` to disable sending all currently cached values. - Subscriptions and desubscriptions can be made for paths. You can use '\*' for all radars including radars still to be discovered. - When first connected all radar meta data will be sent. - When a new radar is discovered all existing streams will also be sent the meta data for the new radar. The recommended way of connecting is to either send `subscribe=none` and then a subscribe to all controls, as in the example below, with a policy of `instant`. The number of updates after the initial cache dump is low, about 2 messages per second. ```json "subscribe": [ { "path": "radars.*.controls.*", "period": 1000 }, ] ``` To receive real-time ARPA target updates, subscribe to the targets path: ```json { "subscribe": [ { "path": "radars.*.targets.*", "policy": "instant" } ] } ``` You can subscribe to both controls and targets simultaneously: ```json { "subscribe": [ { "path": "radars.*.controls.*", "period": 1000 }, { "path": "radars.*.targets.*", "policy": "instant" } ] } ``` ### Controls Example of received meta-data: ```json { "updates": [ { "$source": "mayara", "timestamp": "2026-02-23T18:15:26.409454084Z", "meta": [ { "path": "radars.nav1034A.controls.guardZone1", "value": { "id": 13, "name": "Guard zone", "description": "First guard zone for target detection", "category": "guardZones", "dataType": "zone", "hasEnabled": true, "minValue": -3.141592653589793, "maxValue": 3.141592653589793, "units": "rad", "maxDistance": 100000.0 } }, { "path": "radars.nav1034A.controls.firmwareVersion", "value": { "id": 48, "name": "Firmware version", "description": "Version of the radar firmware", "category": "info", "dataType": "string", "isReadOnly": true } } ] } ] } ``` Example of received data: ```json { "updates": [ { "$source": "mayara", "values": [ { "path": "radars.nav1034A.controls.spokes", "value": { "value": 2048 } } ] } ] } ``` Example of setting a control: ```json { "path": "radars.nav1034A.controls.guardZone1", "value": { "value": 0.735, "endValue": 3.1415, "startDistance": 0, "endDistance": 500, "enabled": true } } ``` Target updates are sent whenever a target's position, motion, or status changes: ```json { "updates": [ { "$source": "mayara", "timestamp": "2025-01-15T10:30:00Z", "values": [ { "path": "radars.nav1034A.targets.1", "value": { "id": 1, "status": "tracking", "position": { "bearing": 0.789, "distance": 1852, "latitude": 52.3702, "longitude": 4.8952 }, "motion": { "course": 3.14159, "speed": 3.34 }, "danger": { "cpa": 150, "tcpa": 324 }, "acquisition": "auto", "sourceZone": 1, "firstSeen": "2025-01-15T10:25:00Z", "lastSeen": "2025-01-15T10:30:00Z" } } ] } ] } ``` Targets are created either automatically (ARPA) or manually (MARPA, via a REST or stream message.) In all cases the targets go through the following states: `acquiring` -> `tracking` -> `lost`. When a target is deleted (either because it has been in status `lost` for a while or a client explicitly deletes it), a final `null` value is sent: ```json { "updates": [ { "$source": "mayara", "timestamp": "2025-01-15T10:32:00Z", "values": [ { "path": "radars.nav1034A.targets.1", "value": null } ] } ] } ``` ## Spoke Data Stream Because radars can produce up to 4 megabytes of data per rotation, this data is transmitted on a separate websocket _per radar_ and is in a binary format. The data is encoded using [Protocol Buffers](https://protobuf.dev/) (protobuf), Google's language-neutral binary serialization format. Protobuf provides compact encoding and fast parsing, with official implementations available for most programming languages including JavaScript, Python, Java, C++, Go, and Rust. The message schema is stable and will not change within a major version (per [semver](https://semver.org/)): ```text syntax = "proto3"; /* * The data stream coming from a radar is a series of spokes. * The number of spokes per revolution is different for each type of * radar and can be found in the capabilities at * .../v2/api/vessels/self/radars/{id}/capabilities as 'spokesPerRevolution'. * The maximum length of each spoke is also defined there, as well as the legend that provides * a lookup table for each byte of data in the spoke. * * The angle and bearing fields below are in terms of spokes, so * range from [0..spokesPerRevolution>. * * Angle is a mandatory field and tells you the rotation of the spoke * relative to the front of the boat, going clockwise. 0 means directly * ahead, spokesPerRevolution / 4 is to starboard, spokesPerRevolution / 2 is directly astern, etc. * * Bearing, if set, means that either the radar or the radar server has * enriched the data with a true bearing, e.g. 0 is directly North, * spokesPerRevolution / 4 is directly West, spokesPerRevolution / 2 is South, etc. * * Likewise, time and lat/lon indicate the best effort when the spoke * was generated, and the lat/lon of the radar at the time of generation. * */ message RadarMessage { message Spoke { uint32 angle = 1; // [0..spokesPerRevolution>, angle from bow optional uint32 bearing = 2; // [0..spokesPerRevolution>, offset from True North uint32 range = 3; // [meters], range in meters of the last pixel in data optional uint64 time = 4; // [millis since UNIX epoch] Time when spoke was generated or received optional double lat = 6; // Location of radar at time of generation optional double lon = 7; // Location of radar at time of generation bytes data = 5; } repeated Spoke spokes = 2; } ``` The URL is found in the `radars` REST response as `spokeDataUrl` or can be constructed as: ```text /signalk/v2/api/vessels/self/radars/{radar_id}/spokes ``` ### Connection Logic This a Javascript example how to set up the connection to receive spokes: ```javascript // Fetch radars const response = await fetch('/signalk/v2/api/vessels/self/radars/') const data = await response.json() // Choose a radar_id from the returned radars const radarId = Object.keys(data)[0] const radar = data[radarId] // Connect to spoke data stream const wsUrl = radar.spokeDataUrl ?? `ws://${location.host}/signalk/v2/api/vessels/self/radars/${radarId}/spokes` const socket = new WebSocket(wsUrl) socket.binaryType = 'arraybuffer' socket.onmessage = (event) => { const spokeData = new Uint8Array(event.data) // Process binary spoke data... } ``` ### Spoke content and the legend Every spoke contains `spoke_len` bytes. The radar API always uses one byte per pixel, with every byte representing a value explained by the `legend` contained in the capabilities. The legend provides a lookup table mapping each byte value to its meaning and suggested display color: - **Byte values 0 to `pixelColors - 1`**: Normal radar returns, ranging from no echo (0) to strongest echo. The `lowReturn`, `mediumReturn`, and `strongReturn` fields indicate thresholds within this range, useful for smoothing or color gradient algorithms. - **Byte value at `targetBorder`**: Indicates the edge of a tracked ARPA target. - **Byte value at `dopplerApproaching`**: Object moving toward the radar (requires Doppler-capable radar). - **Byte value at `dopplerReceding`**: Object moving away from the radar (requires Doppler-capable radar). - **Byte values from `historyStart` onward**: Historical trail data showing where targets were in previous rotations. The `pixels` array provides the complete mapping from byte value to RGBA color. Clients can use this directly for rendering, or implement their own color scheme based on the semantic pixel types (`normal`, `targetBorder`, `dopplerApproaching`, `dopplerReceding`, `history`). If the radar doesn't support a feature, the corresponding legend field will be absent or null (e.g., `dopplerApproaching` and `dopplerReceding` are absent for non-Doppler radars). In a later API release it is likely that the legend will be expanded to contain color mappings for different palettes. ### Spoke skipping Some radars have a high value for `spokesPerRevolution` but actually only produce fewer spokes per each revolution. This is true for Furuno radars but not the other supported radars from Garmin, Navico and Raymarine. The Furuno radars set `hasSparseSpokes` in the capabilities struct to `true`. A conforming GUI must allow for this and either implement some way to expand missing spokes or to reconsider the width of spokes to be from the angle/bearing from the received spoke to the previously received spoke. A typical value for Furuno is to have `spokesPerRevolution = 8192` but the actual # of spokes will be ~ 900. Weirdly enough it is not a "round" figure like 1440, 2048, 512 or 250 like the other radars. ## TypeScript Interfaces ### RadarsResponse ```typescript interface RadarsResponse { version: string radars: Record } interface RadarInfo { name: string brand: string model?: string radarIpAddress: string spokeDataUrl: string streamUrl: string } ``` ### Capabilities ```typescript interface Capabilities { maxRange: number minRange: number supportedRanges: number[] spokesPerRevolution: number maxSpokeLength: number pixelValues: number hasDoppler: boolean hasDualRadar: boolean hasDualRange: boolean hasSparseSpokes: boolean noTransmitSectors: number controls: Record legend: Legend } ``` ### ControlDefinition ```typescript interface ControlDefinition { id: number name: string description: string category: | 'base' | 'targets' | 'guardZones' | 'trails' | 'advanced' | 'installation' | 'info' dataType: 'number' | 'enum' | 'string' | 'button' | 'sector' | 'zone' | 'rect' isReadOnly?: boolean hasEnabled?: boolean minValue?: number maxValue?: number stepValue?: number maxDistance?: number units?: 'm' | 'm/s' | 'rad' | 'rad/s' | 's' descriptions?: Record // For enum types validValues?: number[] // For enum types hasAuto?: boolean hasAutoAdjustable?: boolean autoAdjustMinValue?: number autoAdjustMaxValue?: number } ``` ### ControlValue ```typescript interface ControlValue { value?: number | string units?: | 'm' | 'km' | 'nm' | 'm/s' | 'kn' | 'rad' | 'deg' | 'rad/s' | 'rpm' | 's' | 'min' | 'h' auto?: boolean autoValue?: number enabled?: boolean endValue?: number // End angle for sectors/zones (radians) startDistance?: number // Inner radius for zones (meters) endDistance?: number // Outer radius for zones (meters) x1?: number // Rect: first corner X (meters) y1?: number // Rect: first corner Y (meters) x2?: number // Rect: second corner X (meters) y2?: number // Rect: second corner Y (meters) width?: number // Rect: perpendicular width (meters) timestamp?: string // ISO 8601 timestamp when value was last changed } ``` ### Legend ```typescript interface Legend { lowReturn: number mediumReturn: number strongReturn: number targetBorder: number dopplerApproaching?: number dopplerReceding?: number historyStart: number pixelColors: number pixels: LegendPixel[] } interface LegendPixel { type: 'normal' | 'dopplerApproaching' | 'dopplerReceding' | 'history' color: string } ``` ### Target ```typescript interface Target { id: number status: 'tracking' | 'lost' | 'acquiring' position: { bearing: number // radians [0, 2π) distance: number // meters latitude?: number // omitted if radar position unavailable longitude?: number // omitted if radar position unavailable } motion?: { // omitted if motion not yet computed; present with zeros for stationary targets course: number // radians [0, 2π) speed: number // m/s } danger?: { // omitted if vessels diverging or own-ship motion unavailable cpa: number // meters tcpa: number // seconds } acquisition: 'manual' | 'auto' sourceZone?: number // guard zone (1 or 2) that acquired this target; omitted for manual/Doppler firstSeen: string // ISO 8601 timestamp lastSeen: string // ISO 8601 timestamp } ``` ================================================ FILE: docs/develop/rest-api/resources_api.md ================================================ --- title: Resources API --- # Working with the Resources API The SignalK specification defines a number of resources (routes, waypoints, notes, regions & charts) each with its own path under the root `resources` path _(e.g. `/signalk/v2/api/resources/routes`)_. Additionally, the `/resources` path can be used to host other user defined resource types, each grouped within its own folder _(e.g. `/signalk/v2/api/resources/fishingZones`)_. The _Resources API_ validates requests to these resource paths and passes them to a [Resource Provider plugin](../plugins/resource_provider_plugins.md) for storage and retrieval. Client applications can then use `HTTP` requests to these paths to store (`POST`, `PUT`), retrieve (`GET`) and remove (`DELETE`) resource entries. _Note: the ability to store resource entries is controlled by the server security settings so client applications may need to authenticate for write / delete operations to complete successfully._ ### Retrieving Resources --- Resource entries are retrived by submitting an HTTP `GET` request to the relevant path. For example to return a list of all available routes ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/routes' ``` or alternatively fetch a specific route. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/routes/94052456-65fa-48ce-a85d-41b78a9d2111' ``` A filtered list of entries can be retrieved based on criteria such as: - being within a bounded area - distance from vessel - total entries returned - map zoom level by supplying a query string containing `key | value` pairs in the request. _Example 1: Retrieve waypoints within 50km of the vessel. Note: distance in meters value should be provided._ ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?distance=50000' ``` _Example 2: Retrieve up to 20 waypoints within 90km of the vessel._ ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?distance=90000&limit=20' ``` _Example 3: Retrieve waypoints within a bounded area. Note: the bounded area is supplied as bottom left & top right corner coordinates in the form swLongitude,swLatitude,neLongitude,neLatitude_. ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?bbox=[-135.5,38,-134,38.5]' ``` _Example 4: Return notes for display on a map view at zoom level 5._ ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/notes?zoom=5' ``` It is up to the provider to determine which resource entries are returned for any given zoom level. As a guide we recommend alignment with the criteria in the following document: https://wiki.openstreetmap.org/wiki/Zoom_levels_. ### Deleting Resources --- Resource entries are deleted by submitting an HTTP `DELETE` request to a path containing the `id` of the resource to delete. _Example: Delete from storage the route with id `94052456-65fa-48ce-a85d-41b78a9d2111`._ ```typescript HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/routes/94052456-65fa-48ce-a85d-41b78a9d2111' ``` ### Creating / updating Resources --- **Creating a new resource entry:** Resource entries are created by submitting an HTTP `POST` request to a path for the relevant resource type. ```typescript HTTP POST 'http://hostname:3000/signalk/v2/api/resources/routes' {resource_data} ``` The new resource is created and its `id` (generated by the server) is returned in the response message. ```JSON { "state": "COMPLETED", "statusCode": 200, "id": "94052456-65fa-48ce-a85d-41b78a9d2111" } ``` _Note: Each `POST` will generate a new `id` so if the resource data remains the same duplicate resources will be created._ **Updating a resource entry:** Resource entries are updated by submitting an HTTP `PUT` request to a path for the relevant resource type that includes the resource `id`. ```typescript HTTP PUT 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' ``` As the `PUT` request replaces the record with the supplied `id`, the submitted resource data should contain a complete record for the resource type being written. Each resource type has a specific set of attributes that are required to be supplied before the resource entry can be created or updated. If either the submitted resource data or the resource id are invalid then the operation is aborted.\_ _Note: the submitted resource data is validated against the OpenApi schema definition for the relevant resource type._ --- ## Multiple Providers for a Resource Type The Resources API will allow for multiple plugins to register as a provider for the same resource type. When this scenario occurs the server services request in the following ways: **Listing entries:** When a list of resources is requested _for example:_ ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints' ``` each registered provider will be asked to return matching entries and the server aggregates the results and returns them to the client. --- **Requests for specific resources:** When a request is received for a specific resource _for example:_ ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' HTTP PUT 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111' ``` each registered provider will polled to determine which one owns the entry with the supplied id. The provider with the resource entry is then the target of the requested operation (`getResource()`, `setResource()`, `deleteResource()`). --- **Creating new resource entries:** When a request is received to create a new resource _for example:_ ```typescript HTTP POST 'http://hostname:3000/signalk/v2/api/resources/waypoints' ``` By default the first provider that was registered for that resource type will be the target of the requested operation (`setResource()`). You can view the registered providers for a resource type by making the following request: ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/{resourceType}/_providers' ``` \_Example: `HTTP GET 'http://hostname:3000/signalk/v2/api/resources/charts/\_providers' ```JSON [ "charts", "resources-provider" ] ``` You can retrieve the default provider for a resource type by making the following request: ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/{resourceType}/_providers/_default' ``` Example: `HTTP GET 'http://hostname:3000/signalk/v2/api/resources/charts/\_providers/\_default' ```JSON "resources-provider" ``` You can change the provider used for writing a resource record in the following ways: 1. Per-request by using the `?provider=` query parameter. 2. Setting a "default" provider for a specific resource type **1. Per-request by using the `?provider=` query parameter:** When multiple providers are registered for a resource type the client can specify which provider should be the target of the request by using the query parameter `provider`. _Example:_ ```typescript HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints?provider=my-plugin-id' HTTP GET 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111?provider=my-plugin-id' HTTP PUT 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111?provider=my-plugin-id' HTTP DELETE 'http://hostname:3000/signalk/v2/api/resources/waypoints/94052456-65fa-48ce-a85d-41b78a9d2111?provider=my-plugin-id' HTTP POST 'http://hostname:3000/signalk/v2/api/resources/waypoints?provider=my-plugin-id' ``` the value assigned to `provider` is the `plugin id` of the resource provider plugin. The plugin id can be obtained from the Signal K server url _http://hostname:3000/skServer/plugins_. _Example:_ ```typescript HTTP GET 'http://hostname:3000/plugins' ``` ```JSON [ { "id": "mysk-resource-plugin", // <-- plugin id "name": "Resources Provider", "packageName": "mysk-resource-plugin", "version": "1.3.0", ... }, ... ] ``` **2. Setting a default provider for a resource type:** To change the default provider for a resource type make a POST request to _http://hostname:3000/signalk/v2/api/resources/{resourceType}/\_providers/\_default/{pluginId}_ where `pluginId` is the id of resource provider plugin. _Example: Direct create new chart source entries to `my-chart-plugin`._ ```typescript HTTP POST 'http://hostname:3000/signalk/v2/api/resources/charts/_providers/_default/my-chart-plugin' ``` ================================================ FILE: docs/develop/rest-api/weather_api.md ================================================ --- title: Weather API --- # Weather API The Signal K server Weather API provides a common set of operations for viewing information from weather data sources via a "provider plugin". The provider plugin facilitates the interaction with the weather service and transforms the data into the Signal K data schema. Requests to the Weather API are made to HTTP REST endpoints rooted at `/signalk/v2/api/weather`. Weather API requests require that a postion be supplied which determines the location from which the weather data is sourced. The following weather data sets are supported: - Observations - Forecasts - Warnings Following are examples of the types of requests that can be made. > [!NOTE] > The data available is dependent on the weather service API and provider-plugin. _Example 1: Return the latest observation data for the provided location_ ```javascript GET "/signalk/v2/api/weather/observations?lat=5.432&lon=7.334" ``` _Example 2: Return the last 5 observations for the provided location_ ```javascript GET "/signalk/v2/api/weather/observations?lat=5.432&lon=7.334&count=5" ``` _Example 3: Return the daily forecast for the next seven days for the provided location_ ```javascript GET "/signalk/v2/api/weather/forecasts/daily?lat=5.432&lon=7.334&count=7" ``` _Example 4: Return point forecasts for the next 12 periods (service provider dependant) for the provided location_ ```javascript GET "/signalk/v2/api/weather/forecasts/point?lat=5.432&lon=7.334&count=12" ``` _Example 5: Return current warnings for the provided location_ ```javascript GET "/signalk/v2/api/weather/warnings?lat=5.432&lon=7.334" ``` ## Providers The Weather API supports the registration of multiple weather provider plugins. The first plugin registered is set as the _default_ provider and all requests will be directed to it. Requests can be directed to a specific provider by using the `provider` parameter in the request with the _id_ of the provider plugin. _Example:_ ```javascript GET "/signalk/v2/api/weather/warnings?lat=5.432&lon=7.334?provider=my-weather-plugin" ``` > [!NOTE] Any installed weather provider can be set as the default. _See [Setting the Default provider](#setting-a-provider-as-the-default)_ ### Listing the available Weather Providers To retrieve a list of installed weather provider plugins, submit an HTTP `GET` request to `/signalk/v2/api/weather/_providers`. The response will be an object containing all the registered weather providers, keyed by their identifier, detailing the service `name` and whether it is assigned as the _default_. ```typescript HTTP GET "/signalk/v2/api/weather/_providers" ``` _Example: List of registered weather providers showing that `open-meteo` is assigned as the default._ ```JSON { "open-meteo": { "provider":"OpenMeteo", "isDefault": true }, "openweather": { "provider":"OpenWeather", "isDefault": false } } ``` ### Getting the Default Provider identifier To get the id of the _default_ provider, submit an HTTP `GET` request to `/signalk/v2/api/weather/_providers/_default`. _Example:_ ```typescript HTTP GET "//signalk/v2/api/weather/_providers" ``` _Response:_ ```JSON { "id":"open-meteo" } ``` ### Setting a Provider as the Default To set / change the weather provider that requests will be directed, submit an HTTP `POST` request to `/signalk/v2/api/weather/_providers/_default/{id}` where `{id}` is the identifier of the weather provider to use as the _default_. _Example:_ ```typescript HTTP POST "/signalk/v2/api/weather/_providers/_default/openweather" ``` ================================================ FILE: docs/develop/webapps.md ================================================ --- title: WebApps --- # WebApps and Components Signal K Server provides the following ways to add web-based user interfaces to enhance functionality and usability: 1. **Standalone WebApps** are web applications that when launched, the server Admin UI disappears and the webapp controls the whole page (browser window / tab). 2. **Embedded WebApps** are web applications that when launched, are **embedded in the server Admin UI**, leaving the toolbar and menu available to the user. 3. **Embedded Plugin Configuration Forms** are forms provided by a plugin that the server embeds within the _Plugin Config_ screen to replace the generic form rendered using the plugin _configuration schema_. This allows a richer set of controls to be provided for the user to configure the plugin compared to the more generice server generated form provides. ![calibration](../img/calibration.png 'Calibration plugin configuration form') 4. **Embedded Components** are individual UI components provided by a plugin or a webapp. They are listed in the _Addons_ section at the bottom of the _Webapps_ page of the Admin UI. More a concept than a fully implemented feature at this stage, the idea is to allow a plugin to add individual components to different parts of the server UI. All Plugins, WebApps and Components can be installed via the _Appstore_. ## WebApp Structure All WebApps (like plugins) are installed with `npm`, either from the npm registry or from your own Github repository. Only WebApps that are relevant for all users should be published to `npm` to be made available in the _Appstore_ of all Signal K Servers. _Note: Private plugins need not be published to `npm` - see the documentation for [npm install](https://docs.npmjs.com/cli/v6/commands/npm-install) for details._ The basic structure of a webapp is: - A folder named `public` that contains the html, JavaScript and resource files such as images, fonts and style sheets. This folder is automatically mounted by the server so that the webapp is available after installation and the server restarted. - `package.json` containing special keywords that classifies the webapp: - `signalk-webapp` - standalone webapp - `signalk-embeddable-webapp` - embeddable webapp - `signalk-plugin-configurator` - plugin configuration form This structure is all that is needed for a standalone webapp. You can also include the following section in `package.json` to control how your webapp appears in the _Webapps_ list: ```JSON "signalk": { "appIcon": "./assets/icons/icon-72x72.png", "displayName": "Freeboard-SK" }, ``` where: - `appIcon` is the path (relative to the `public` directory) to an image within the package to display in the webapp list. The image should be at least 72x72 pixels in size. - `displayName` is the text you want to appear as the name in the webapp list. _(By default the \_name_ attribute in the `package.json` is used.)\_. Displayname is also used in an automatic redirect from the root of the server: if you have a webapp with displayName `foo` and you access it using for example the url http://foo.bar.org:3000 the first part of the hostname matches the webapp's displayName and you will be redirected to it instead of the default landingPage, the Admin webapp. With this mechanism you can add easy to access DNS names to each webapp, including .local names. See also [Working Offline](./README.md#offline-use). ## Application Data: Storing Webapp Data on the Server Application Data is only supported if security is turned on. It supports two namespaces, one for _global data_ and one for _user specific data_. For example, a client might want to store boat specific gauge configuration globally so that other users have access to it. Otherwise, it could use the user area to store user specific preferences. The data is structured and manipulated in JSON format. Global storage: `/signalk/v1/applicationData/global/:appid/:version` User storage: `/signalk/v1/applicationData/user/:appid/:version` There are two ways to update or add stored data: - You can POST any json data to any path: ``` POST /signalk/v1/applicationData/user/my-application/1.0/unitPreferences { "shortDistance": "m", "longDistance": "km" } ``` - You can also use json patch format (http://jsonpatch.com): ``` POST /signalk/v1/applicationData/user/my-application/1.0 [ { "op": "add", "path": "/unitPreferences", "value": { "shortDistace": "m" } }, { "op": "add", "path": "/unitPreferences/longDistance", "value": "km"} ] ``` Use an HTTP GET request to retrieve data from the server: `GET /signalk/v1/applicationData/user/my-application/1.0/unitPreferences/shortDistance` You can just GET the list of keys: ``` GET /signalk/v1/applicationData/user/my-application/1.0/unitPreferences?keys=true [ "longDistance", "shortDistance"] ``` You get can a list of available versions: ``` GET /signalk/v1/applicationData/user/my-application [ "1.0", "1.1"] ``` ## Discovering Server Features To assist in tailoring a WebApps UI, it can "discover" the features supported by the server by sending a request to `/signalk/v2/features`. The response wil contain an object detailing the available APIs and Plugins. You can use the `enabled` parameter to specify to only return enabled or disabled features. To list only enabled features: `/signalk/v2/features?enable=1` To list only disabled features: `/signalk/v2/features?enable=0` _Example response:_ ```JSON { "apis": [ "resources","course" ], "plugins": [ { "id": "anchoralarm", "name": "Anchor Alarm", "version": "1.13.0", "enabled": true }, { "id": "autopilot", "name": "Autopilot Control", "version": "1.4.0", "enabled": false }, { "id": "sk-to-nmea2000", "name": "Signal K to NMEA 2000", "version": "2.17.0", "enabled": false }, { "id": "udp-nmea-sender", "name": "UDP NMEA0183 Sender", "version": "2.0.0", "enabled": false } ] } ``` ## Embedded Components and Admin UI / Server interfaces Embedded components are implemented using [Module Federation](https://module-federation.io/) and [React Code Splitting](https://react.dev/reference/react/lazy). _Note: There is no keyword for a module that provides only embedded components, use `signalk-webapp` instead._ You need to configure your build tool (Webpack or Vite) to create the necessary code for federation and expose the component with fixed names: - embeddable webapp: `./AppPanel` - plugin configuration form: `./PluginConfigurationPanel` - embedded component: `./AddonPanel` The exposed modules need to `export default` a React component. Functional components with hooks are recommended. The server dependencies like `reactstrap` can and should be used. Add `@signalk/server-admin-ui-dependencies` as a dependency to the webapp, it defines the dependencies used by the server admin UI. ### Webpack (var library) With Webpack's ModuleFederationPlugin, use `library.type: 'var'` with a safe module name derived from the package name: ```javascript library: { type: 'var', name: packageJson.name.replace(/[-@/]/g, '_') }, ``` The server loads these via a classic ` ================================================ FILE: packages/server-admin-ui/package.json ================================================ { "name": "@signalk/server-admin-ui", "version": "2.26.0", "description": "Signal K server admin webapp", "repository": { "type": "git", "url": "git+https://github.com/SignalK/signalk-server.git", "directory": "packages/server-admin-ui" }, "author": "Dirk Wahrheit, Scott Bender, Teppo Kurki", "contributors": [ { "name": "Teppo Kurki" }, { "name": "Scott Bender" } ], "license": "MIT", "type": "module", "keywords": [ "signalk-webapp" ], "signalk": { "appIcon": "./img/signal-k-logo-image.svg", "displayName": "Admin UI" }, "devDependencies": { "@babel/core": "^7.11.6", "@babel/preset-react": "^7.10.4", "@fortawesome/fontawesome-free": "^5.15.1", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "^3.1.1", "@module-federation/vite": "^1.9.4", "@rolldown/plugin-babel": "^0.2.2", "@rjsf/core": "^5.24.13", "@rjsf/utils": "^5.24.13", "@rjsf/validator-ajv8": "^5.24.13", "@signalk/server-admin-ui-dependencies": "2.23.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/escape-html": "^1.0.4", "@types/lodash.remove": "^4.7.9", "@types/lodash.set": "^4.3.9", "@types/lodash.uniq": "^4.5.9", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.2", "ansi-to-html": "^0.6.14", "babel-plugin-react-compiler": "^1.0.0", "bootstrap": "^5.3.3", "buffer": "^6.0.3", "dayjs": "^1.11.13", "escape-html": "^1.0.3", "font-awesome": "^4.7.0", "html-react-parser": "^5.2.5", "jsdom": "^27.4.0", "jsonlint-mod": "^1.7.6", "lodash.remove": "^4.7.0", "lodash.set": "^4.3.2", "lodash.uniq": "^4.5.0", "mathjs": "^15.2.0", "path-browserify": "^1.0.1", "react": "^19.2.0", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.0", "react-infinite-scroll-component": "^6.1.0", "react-json-tree": "^0.20.0", "react-router-dom": "^6.28.0", "react-select": "^5.10.2", "reconnecting-websocket": "^4.4.0", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.81.0", "simple-line-icons": "^2.5.5", "typescript": "^5.7.2", "vite": "^8.0.9", "vitest": "^4.1.2" }, "scripts": { "prepublishOnly": "npm run build", "dev": "vite", "watch": "vite build --watch", "build": "vite build", "preview": "vite preview", "format": "prettier --write src/", "lint": "eslint --fix", "clean": "rimraf ./public", "bundle-analyzer": "vite-bundle-visualizer", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, "dependencies": { "zustand": "^5.0.10" } } ================================================ FILE: packages/server-admin-ui/public_src/index.html ================================================ %ADDONSCRIPTS% Signal K Server
================================================ FILE: packages/server-admin-ui/scss/_bootstrap-variables.scss ================================================ // Bootstrap overrides // // Color system // $white: #fff; $gray-100: #f0f3f5; $gray-200: #c2cfd6; $gray-300: #a4b7c1; $gray-400: #869fac; $gray-500: #678898; $gray-600: #536c79; $gray-700: #3e515b; $gray-800: #29363d; $gray-900: #151b1e; $black: #000 !default; $blue: #003399; $indigo: #6610f2 !default; $purple: #6f42c1 !default; $pink: #e83e8c !default; $red: #f86c6b; $orange: #f8cb00; $yellow: #ffcc00 !default; $green: #00cd79; $teal: #20c997 !default; $cyan: #63c2de; $colors: ( blue: $blue, indigo: $indigo, purple: $purple, pink: $pink, red: $red, orange: $orange, yellow: $yellow, green: $green, teal: $teal, cyan: $cyan, white: $white, gray: $gray-600, gray-dark: $gray-800 ); $theme-colors: ( primary: $blue, secondary: $gray-700, success: $green, info: $cyan, warning: $yellow, danger: $red, light: $gray-200, dark: $gray-800 ); // Options // // Quickly modify global styling by enabling or disabling optional features. $enable-transitions: true; $enable-rounded: true; // Body // // Settings for the `` element. $body-bg: #e4e5e6; // Typography // // Font, line-height, and color for body text, headings, and more. $font-size-base: 0.875rem; // Breadcrumbs $breadcrumb-bg: #fff; $breadcrumb-margin-bottom: 1.5rem; // Cards $card-bg: $white; $card-border-color: $gray-200; $card-cap-bg: $gray-100; // Dropdowns $dropdown-padding-y: 0; $dropdown-border-color: $gray-200; $dropdown-divider-bg: $gray-100; // Buttons $btn-secondary-border: $gray-300; // Progress bars $progress-bg: $gray-100; // Tables $table-bg: $white; $table-bg-accent: $gray-100; $table-bg-hover: $gray-100; // Forms $input-bg: $white; $input-focus-bg: $white; $input-group-addon-bg: $gray-100; $input-border-color: $gray-200; $input-group-addon-border-color: $gray-200; // List groups $list-group-bg: $white; ================================================ FILE: packages/server-admin-ui/scss/_core-variables.scss ================================================ // core overrides ================================================ FILE: packages/server-admin-ui/scss/_custom.scss ================================================ // Here you can add other styles // Navbar toggler - remove border to match CoreUI v1 styling .app-header .navbar-toggler { border: none; outline: none; -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); &:focus { box-shadow: none; } } // RJSF Form Styling // Mixin for button state variants to reduce duplication @mixin button-variant( $bg-color, $border-color, $hover-bg, $hover-border, $shadow-color ) { background-color: $bg-color !important; border-color: $border-color !important; color: white !important; &:hover { background-color: $hover-bg !important; border-color: $hover-border !important; } &:focus { background-color: $hover-bg !important; border-color: $hover-border !important; box-shadow: 0 0 0 0.2rem $shadow-color !important; } } form.rjsf { // General form element styling label { margin-bottom: 0; } div > p.field-description { font-size: 0.7rem; font-style: italic; margin-bottom: 0px; } input[type='text'], input[type='number'], input[type='email'], input[type='url'], input[type='password'], textarea { width: 100%; } // Add spacing between form fields (Bootstrap 5 compatibility) fieldset { margin-bottom: 1rem; > div { margin-bottom: 0.75rem; } } // Add spacing after checkboxes input[type='checkbox'] { margin-right: 0.5rem; } .form-check-input { margin-right: 0.5rem; } .form-check-label { margin-left: 0.25rem; } // Checkbox container spacing .checkbox, .form-check { margin-bottom: 0.5rem; } div.row.array-item { background-color: aliceblue; margin-bottom: 3px; } // Custom button styling for RJSF buttons to match original appearance // Add button styling - cyan color button.btn-add, button.array-item-add button { @include button-variant( #20a8d8, // background #20a8d8, // border #1985ac, // hover background #1985ac, // hover border rgba(32, 168, 216, 0.5) // focus shadow ); } // Remove button styling - red color button.array-item-remove { @include button-variant( #f86c6b, // background #f86c6b, // border #f63c3a, // hover background #f63c3a, // hover border rgba(248, 108, 107, 0.5) // focus shadow ); } // Array button styling .array-button-style { flex: 1 1 0%; padding-left: 6px; padding-right: 6px; font-weight: bold; } // Button group flexbox layout .btn-group-flex { display: flex; justify-content: space-around; } } // react-bootstrap's Form.Group does not add margin like reactstrap's FormGroup did. // Reactstrap added mb-3 automatically; replicate that for horizontal form rows. .card-body form .row, .form-horizontal .row { margin-bottom: 1rem; } @media (max-width: 767px) { .container-fluid, .row { padding: 0 2px !important; } .navbar-header { margin: 0px; } } /** Utility class to attempt to wrap text in a more balanced way. **/ .text-pretty { text-wrap: balance; /* Fallback for older browsers */ text-wrap: pretty; /* Future spec */ } // Bootstrap 5 compatibility: Remove default link underlines to match Bootstrap 4 styling // Affects Dashboard activity lists and status tables .horizontal-bars a, .table a { text-decoration: none; &:hover { text-decoration: underline; } } // Navbar FontAwesome icon styling to match original FA4 appearance .app-header.navbar { .nav-link { // Ensure FA6 SVG icons align properly with text svg { vertical-align: -0.125em; margin-right: 0.35rem; } } // Dropdown toggle icon sizing .dropdown-toggle svg { font-size: 1.25rem; margin-right: 0; } } // Dashboard callout values - use body color instead of primary for readability .callout-primary { .h4, .h5 { color: inherit; } } // Sidebar FontAwesome SVG icon styling .sidebar { .nav-link { svg.nav-icon { display: inline-block; width: 20px; margin: 0 0.5rem 0 0; font-size: 14px; color: #536c79; text-align: center; } &.active svg.nav-icon { color: #003399; } &:hover svg.nav-icon { color: #fff; } } .nav-dropdown-toggle svg.nav-icon { display: inline-block; width: 20px; margin: 0 0.5rem 0 0; font-size: 14px; color: #536c79; text-align: center; } // Indent dropdown child items that don't have icons .nav-dropdown-items .nav-link { padding-left: calc(1rem + 20px + 0.5rem); } // Hide parent badges when dropdown is expanded (children show their own) .nav-dropdown.open > .nav-dropdown-toggle > .badge { display: none; } } // Ensure btn-success has white text (Bootstrap 5 may calculate dark text for some greens) .btn-success, .btn-success:hover, .btn-success:focus, .btn-success:active { --bs-btn-color: #fff; --bs-btn-hover-color: #fff; --bs-btn-active-color: #fff; color: #fff !important; } // Source Priorities: nested table in Priorities column should have white background .table-striped .table { --bs-table-bg: #fff; background-color: #fff; } // Ensure btn-danger has white text .btn-danger, .btn-danger:hover, .btn-danger:focus, .btn-danger:active { --bs-btn-color: #fff; --bs-btn-hover-color: #fff; --bs-btn-active-color: #fff; color: #fff !important; } // Ensure badges have white text .badge.text-bg-success, .badge.text-bg-danger, .badge.bg-success, .badge.bg-danger { color: #fff !important; } // Plugin config: scrollable list and connected panels on desktop @media (min-width: 1200px) { .plugin-list-container { max-height: calc(100vh - 280px); overflow-y: auto; } .plugin-config-row > :first-child .card { border-top-right-radius: 0; border-bottom-right-radius: 0; } .plugin-config-row > :last-child .card { border-top-left-radius: 0; border-bottom-left-radius: 0; border-left: 0; } } ================================================ FILE: packages/server-admin-ui/scss/core/_animate.scss ================================================ // scss-lint:disable all .animated { animation-duration: 1s; // animation-fill-mode: both; } .animated.infinite { animation-iteration-count: infinite; } .animated.hinge { animation-duration: 2s; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .fadeIn { animation-name: fadeIn; } ================================================ FILE: packages/server-admin-ui/scss/core/_aside.scss ================================================ @use 'sass:color'; .aside-menu { z-index: $zindex-sticky - 1; width: $aside-menu-width; color: $aside-menu-color; background: $aside-menu-bg; @include borders($aside-menu-borders); .nav-tabs { border-color: $border-color; .nav-link { padding: $aside-menu-nav-padding-y $aside-menu-nav-padding-x; color: $body-color; border-top: 0; &.active { color: theme-color('primary'); border-right-color: $border-color; border-left-color: $border-color; } } .nav-item:first-child { .nav-link { border-left: 0; } } } .tab-content { position: relative; overflow-x: hidden; overflow-y: auto; border: 0; border-top: 1px solid $border-color; -ms-overflow-style: -ms-autohiding-scrollbar; &::-webkit-scrollbar { width: 10px; margin-left: -10px; -webkit-appearance: none; } // &::-webkit-scrollbar-button { } &::-webkit-scrollbar-track { background-color: color.adjust($aside-menu-bg, $lightness: 5%); border-right: 1px solid color.adjust($aside-menu-bg, $lightness: -5%); border-left: 1px solid color.adjust($aside-menu-bg, $lightness: -5%); } // &::-webkit-scrollbar-track-piece { } &::-webkit-scrollbar-thumb { height: 50px; background-color: color.adjust($aside-menu-bg, $lightness: -10%); background-clip: content-box; border-color: transparent; border-style: solid; border-width: 1px 2px; } .tab-pane { padding: 0; } } } ================================================ FILE: packages/server-admin-ui/scss/core/_avatars.scss ================================================ .img-avatar { border-radius: 50em; } .avatar { $width: 36px; $status-width: 10px; @include avatar($width, $status-width); } .avatar.avatar-xs { $width: 20px; $status-width: 8px; @include avatar($width, $status-width); } .avatar.avatar-sm { $width: 24px; $status-width: 8px; @include avatar($width, $status-width); } .avatar.avatar-lg { $width: 72px; $status-width: 12px; @include avatar($width, $status-width); } .avatars-stack { .avatar.avatar-xs { margin-right: -10px; } // .avatar.avatar-sm { // // } .avatar { margin-right: -15px; transition: margin-left $layout-transition-speed, margin-right $layout-transition-speed; &:hover { margin-right: 0 !important; } } // .avatar.avatar-lg { // // } } ================================================ FILE: packages/server-admin-ui/scss/core/_badge.scss ================================================ // Bootstrap 5: .badge-pill is replaced by .rounded-pill // Keeping for backwards compatibility .badge-pill { border-radius: var(--bs-border-radius-pill, 50rem); } ================================================ FILE: packages/server-admin-ui/scss/core/_breadcrumb-menu.scss ================================================ .breadcrumb-menu { margin-left: auto; &::before { display: none; } .btn-group { vertical-align: top; } .btn { padding: 0 $input-btn-padding-x; color: $text-muted; vertical-align: top; border: 0; &:hover, &.active { color: $body-color; background: transparent; } } .open { .btn { color: $body-color; background: transparent; } } .dropdown-menu { min-width: 180px; line-height: $line-height-base; } } ================================================ FILE: packages/server-admin-ui/scss/core/_breadcrumb.scss ================================================ .breadcrumb { position: relative; @include borders($breadcrumb-borders); } ================================================ FILE: packages/server-admin-ui/scss/core/_buttons.scss ================================================ @use 'sass:color'; button { cursor: pointer; } .btn { .badge { position: absolute; top: 2px; right: 6px; font-size: 9px; } } .btn-transparent { color: #fff; background-color: transparent; border-color: transparent; } .btn { [class^='icon-'], [class*=' icon-'] { display: inline-block; margin-top: -2px; vertical-align: middle; } } .btn-facebook, .btn-twitter, .btn-linkedin, .btn-flickr, .btn-tumblr, .btn-xing, .btn-github, .btn-html5, .btn-openid, .btn-stack-overflow, .btn-youtube, .btn-css3, .btn-dribbble, .btn-google-plus, .btn-instagram, .btn-pinterest, .btn-vk, .btn-yahoo, .btn-behance, .btn-dropbox, .btn-reddit, .btn-spotify, .btn-vine, .btn-foursquare, .btn-vimeo { position: relative; overflow: hidden; color: #fff !important; text-align: center; &::before { position: absolute; top: 0; left: 0; display: block; font-family: 'FontAwesome'; font-style: normal; font-weight: normal; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; } &:hover { color: #fff; } &.icon { span { display: none; } } &.text { &::before { display: none; } span { margin-left: 0 !important; } } @include button-social-size( $input-btn-padding-y, $input-btn-padding-x, $font-size-base, $line-height-base, $btn-border-radius ); &.btn-lg { @include button-social-size( $input-btn-padding-y-lg, $input-btn-padding-x-lg, $font-size-lg, $line-height-lg, $btn-border-radius-lg ); } &.btn-sm { @include button-social-size( $input-btn-padding-y-sm, $input-btn-padding-x-sm, $font-size-sm, $line-height-sm, $btn-border-radius-sm ); } } .btn-facebook { $color: $facebook; background: $color; &::before { content: '\f09a'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-twitter { $color: $twitter; background: $color; &::before { content: '\f099'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-linkedin { $color: $linkedin; background: $color; &::before { content: '\f0e1'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-flickr { $color: $flickr; background: $color; &::before { content: '\f16e'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-tumblr { $color: $tumblr; background: $color; &::before { content: '\f173'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-xing { $color: $xing; background: $color; &::before { content: '\f168'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-github { $color: $github; background: $color; &::before { content: '\f09b'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-html5 { $color: $html5; background: $color; &::before { content: '\f13b'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-openid { $color: $openid; background: $color; &::before { content: '\f19b'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-stack-overflow { $color: $stack-overflow; background: $color; &::before { content: '\f16c'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-css3 { $color: $css3; background: $color; &::before { content: '\f13c'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-youtube { $color: $youtube; background: $color; &::before { content: '\f167'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-dribbble { $color: $dribbble; background: $color; &::before { content: '\f17d'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-google-plus { $color: $google-plus; background: $color; &::before { content: '\f0d5'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-instagram { $color: $instagram; background: $color; &::before { content: '\f16d'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-pinterest { $color: $pinterest; background: $color; &::before { content: '\f0d2'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-vk { $color: $vk; background: $color; &::before { content: '\f189'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-yahoo { $color: $yahoo; background: $color; &::before { content: '\f19e'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-behance { $color: $behance; background: $color; &::before { content: '\f1b4'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-dropbox { $color: $dropbox; background: $color; &::before { content: '\f16b'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-reddit { $color: $reddit; background: $color; &::before { content: '\f1a1'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-spotify { $color: $spotify; background: $color; &::before { content: '\f1bc'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-vine { $color: $vine; background: $color; &::before { content: '\f1ca'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-foursquare { $color: $foursquare; background: $color; &::before { content: '\f180'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } .btn-vimeo { $color: $vimeo; background: $color; &::before { content: '\f194'; background: color.adjust($color, $lightness: -5%); } &:hover { background: color.adjust($color, $lightness: -5%); &::before { background: color.adjust($color, $lightness: -10%); } } } ================================================ FILE: packages/server-admin-ui/scss/core/_callout.scss ================================================ .callout { position: relative; padding: 0 $spacer; margin: $spacer 0; border: 0 solid $border-color; border-left-width: 0.25rem; @if $enable-rounded { border-radius: 0.25rem; } .chart-wrapper { position: absolute; top: 10px; left: 50%; float: right; width: 50%; } } .callout-bordered { border: 1px solid $border-color; border-left-width: 0.25rem; } .callout code { border-radius: 0.25rem; } .callout h4 { margin-top: 0; margin-bottom: 0.25rem; } .callout p:last-child { margin-bottom: 0; } .callout + .callout { margin-top: -0.25rem; } .callout-default { border-left-color: $text-muted; h4 { color: $text-muted; } } @each $color, $value in $theme-colors { .callout-#{$color} { border-left-color: $value; h4 { color: $value; } } } ================================================ FILE: packages/server-admin-ui/scss/core/_card.scss ================================================ @use 'sass:color'; @use 'sass:math'; .card { margin-bottom: 1.5 * $spacer; // Cards with color accent @each $color, $value in $theme-colors { &.bg-#{$color} { border-color: color.adjust($value, $lightness: -12.5%); .card-header { background-color: color.adjust($value, $lightness: -3%); border-color: color.adjust($value, $lightness: -12.5%); } } } } .text-white .text-muted { color: rgba(255, 255, 255, 0.6) !important; } .card-header { .icon-bg { display: inline-body; padding: $card-spacer-y $card-spacer-x !important; margin-top: -$card-spacer-y; margin-right: $card-spacer-x; margin-bottom: -$card-spacer-y; margin-left: -$card-spacer-x; line-height: inherit; color: $card-icon-color; vertical-align: bottom; background: $card-icon-bg; border-right: $card-border-width solid $card-border-color; } .nav.nav-tabs { margin-top: -$card-spacer-y; margin-bottom: -$card-spacer-y; border-bottom: 0; .nav-item { border-top: 0; } .nav-link { padding: $card-spacer-y math.div($card-spacer-x, 2); color: $text-muted; border-top: 0; &.active { color: $body-color; background: #fff; } } } &.card-header-inverse { color: #fff; } .btn { margin-top: -$input-btn-padding-y; } .btn-sm { margin-top: -$input-btn-padding-y-sm; } .btn-lg { margin-top: -$input-btn-padding-y-lg; } } // .card-footer { ul { display: table; width: 100%; padding: 0; margin: 0; table-layout: fixed; li { display: table-cell; padding: 0 $card-spacer-x; text-align: center; } } } [class*='card-outline-'] { .card-body { background: #fff !important; } &.card-outline-top { border-top-width: 2px; border-right-color: $border-color; border-bottom-color: $border-color; border-left-color: $border-color; } } // Cards with color accent @each $color, $value in $theme-colors { .card-accent-#{$color} { @include card-accent-variant($value); } } // Card Actions .card-header { > i { margin-right: math.div($spacer, 2); } .card-actions { position: absolute; top: 0; right: 0; //height: inherit; a, button { display: block; float: left; width: 50px; padding: $card-spacer-y 0; margin: 0 !important; color: $body-color; text-align: center; background: transparent; border: 0; border-left: 1px solid $border-color; box-shadow: 0; &:hover { text-decoration: none; } [class^='icon-'], [class*=' icon-'] { display: inline-body; vertical-align: middle; } i { display: inline-body; transition: 0.4s; } .r180 { transform: rotate(180deg); } } .input-group { width: 230px; margin: 6px; .input-group-addon { background: #fff; } input { border-left: 0; } } } } .card-full { margin-top: -$spacer; margin-right: math.div(-$grid-gutter-width, 2); margin-left: math.div(-$grid-gutter-width, 2); border: 0; border-bottom: $card-border-width solid $border-color; } @include media-breakpoint-up(sm) { .card-columns { &.cols-2 { column-count: 2; } } } .card { &.drag, .drag { cursor: move; } } .card-placeholder { background: rgba(0, 0, 0, 0.025); border: 1px dashed $gray-300; } ================================================ FILE: packages/server-admin-ui/scss/core/_charts.scss ================================================ .chart-wrapper { canvas { width: 100% !important; } } // scss-lint:disable QualifyingElement base-chart.chart { display: block !important; } ================================================ FILE: packages/server-admin-ui/scss/core/_dropdown-menu-right.scss ================================================ // Temp fix for reactstrap .app-header { .navbar-nav { .dropdown-menu-right { right: auto; } } } ================================================ FILE: packages/server-admin-ui/scss/core/_dropdown.scss ================================================ // Links, buttons, and more within the dropdown menu .dropdown-item { position: relative; padding: 0.375rem 0.75rem; border-bottom: 1px solid $dropdown-border-color; &:last-child { border-bottom: 0; } i { display: inline-block; width: 20px; margin-right: 10px; margin-left: -10px; color: $dropdown-border-color; text-align: center; } .badge { position: absolute; right: 10px; margin-top: 2px; } } // Dropdown section headers .dropdown-header { padding: 8px 20px; background: $dropdown-divider-bg; border-bottom: 1px solid $dropdown-border-color; .btn { margin-top: -7px; color: $dropdown-header-color; &:hover { color: $body-color; } &.pull-right { margin-right: -20px; } } } .dropdown-menu-lg { width: 250px; } .app-header { .navbar-nav { .dropdown-menu { position: absolute; } // Menu positioning // // Add extra class to `.dropdown-menu` to flip the alignment of the dropdown // menu with the parent. .dropdown-menu-right { right: 0; left: auto; // Reset the default from `.dropdown-menu` } .dropdown-menu-left { right: auto; left: 0; } } } ================================================ FILE: packages/server-admin-ui/scss/core/_footer.scss ================================================ .app-footer { display: flex; flex-wrap: wrap; align-items: center; padding: 0 $spacer; color: $footer-color; background: $footer-bg; @include borders($footer-borders); } ================================================ FILE: packages/server-admin-ui/scss/core/_grid.scss ================================================ @use 'sass:math'; .row.row-equal { padding-right: math.div($grid-gutter-width, 4); padding-left: math.div($grid-gutter-width, 4); margin-right: math.div($grid-gutter-width, -2); margin-left: math.div($grid-gutter-width, -2); [class*='col-'] { padding-right: math.div($grid-gutter-width, 4); padding-left: math.div($grid-gutter-width, 4); } } .main .container-fluid { padding: 30px; height: 100%; } ================================================ FILE: packages/server-admin-ui/scss/core/_input-group.scss ================================================ .input-group-addon, .input-group-btn { min-width: 40px; white-space: nowrap; vertical-align: middle; // Match the inputs } ================================================ FILE: packages/server-admin-ui/scss/core/_layout.scss ================================================ @use 'sass:math'; @use 'sass:color'; // IE10&11 Flexbox fix @media all and (-ms-high-contrast: none) { html { display: flex; flex-direction: column; } } // app-dashboard and app-root are Angular2+ selectors. You can add here your own selectors if you need. .app, app-dashboard, app-root { display: flex; flex-direction: column; min-height: 100vh; min-height: 100dvh; } .app-header { flex: 0 0 $navbar-height; } .app-footer { flex: 0 0 $footer-height; } .app-body { display: flex; flex-direction: row; flex-grow: 1; overflow-x: hidden; .main { flex: 1; min-width: 0; } .sidebar { // $sidebar-width is the width of the columns flex: 0 0 $sidebar-width; // put the nav on the left order: -1; } .aside-menu { // $aside-menu-width is the width of the columns flex: 0 0 $aside-menu-width; } } // // header // .header-fixed { .app-header { position: fixed; z-index: $zindex-sticky; width: 100%; } .app-body { margin-top: $navbar-height; } } // // Sidebar // .sidebar-hidden { .sidebar { margin-left: -$sidebar-width; } } .sidebar-fixed { .sidebar { position: fixed; z-index: $zindex-sticky - 1; width: $sidebar-width; height: calc(100vh - #{$navbar-height}); height: calc(100dvh - #{$navbar-height}); // margin-top: - $navbar-height; // .sidebar-nav { // height: calc(100vh - #{$navbar-height}); // } } .main, .app-footer { margin-left: $sidebar-width; } &.sidebar-hidden { .main, .app-footer { margin-left: 0; } } } .sidebar-off-canvas { .sidebar { position: fixed; z-index: $zindex-sticky - 1; height: calc(100vh - #{$navbar-height}); height: calc(100dvh - #{$navbar-height}); } } @include media-breakpoint-up(lg) { .sidebar-compact { .sidebar { flex: 0 0 $sidebar-compact-width; } &.sidebar-hidden { .sidebar { margin-left: -$sidebar-compact-width; } } &.sidebar-fixed { .main, .app-footer { margin-left: $sidebar-compact-width; } .sidebar { width: $sidebar-compact-width; } &.sidebar-hidden { .main, .app-footer { margin-left: 0; } } } } .sidebar-minimized { .sidebar { flex: 0 0 $sidebar-minimized-width; } &.sidebar-hidden { .sidebar { margin-left: -$sidebar-minimized-width; } } &.sidebar-fixed { .main, .app-footer { margin-left: $sidebar-minimized-width; } .sidebar { width: $sidebar-minimized-width; } &.sidebar-hidden { .main, .app-footer { margin-left: 0; } } } } } // // Aside Menu // .aside-menu-hidden { .aside-menu { margin-right: -$aside-menu-width; } } .aside-menu-fixed { .aside-menu { position: fixed; right: 0; height: 100%; .tab-content { height: calc( 100vh - #{$aside-menu-nav-padding-y * 2 + $font-size-base} - #{$navbar-height} ); height: calc( 100dvh - #{$aside-menu-nav-padding-y * 2 + $font-size-base} - #{$navbar-height} ); } } .main, .app-footer { margin-right: $aside-menu-width; } &.aside-menu-hidden { .main, .app-footer { margin-right: 0; } } } .aside-menu-off-canvas { .aside-menu { position: fixed; right: 0; z-index: $zindex-sticky - 1; height: 100%; .tab-content { height: calc( 100vh - #{$aside-menu-nav-padding-y * 2 + $font-size-base} - #{$navbar-height} ); height: calc( 100dvh - #{$aside-menu-nav-padding-y * 2 + $font-size-base} - #{$navbar-height} ); } } } // // Breadcrumb // .breadcrumb-fixed { .main { $breadcrumb-height: 2 * $breadcrumb-padding-y + $font-size-base + 1.5 * $spacer; padding-top: $breadcrumb-height; } .breadcrumb { position: fixed; top: $navbar-height; right: 0; left: 0; z-index: $zindex-sticky - 2; } // if sidebar + main + aside .main:nth-child(2) { .breadcrumb { right: $aside-menu-width; left: $sidebar-width; } } // if sidebar + main .main:first-child { .breadcrumb { right: $aside-menu-width; left: 0; } } // if main + aside .main:last-child { .breadcrumb { right: 0; } } &.sidebar-minimized { .main .breadcrumb { left: $sidebar-minimized-width; } } &.sidebar-hidden, &.sidebar-off-canvas { .main .breadcrumb { left: 0; } } &.aside-menu-hidden, &.aside-menu-off-canvas { .main .breadcrumb { right: 0; } } } // // Footer // .footer-fixed { .app-footer { position: fixed; right: 0; bottom: 0; left: 0; z-index: $zindex-sticky; height: $footer-height; } .app-body { margin-bottom: $footer-height; } } // // Animations // .app-header, .app-footer, .sidebar, .main, .aside-menu { transition: margin-left $layout-transition-speed, margin-right $layout-transition-speed, width $layout-transition-speed, flex $layout-transition-speed; } .sidebar-nav { transition: width $layout-transition-speed; } .breadcrumb { transition: left $layout-transition-speed, right $layout-transition-speed, width $layout-transition-speed; } // // Mobile layout // @include media-breakpoint-down(md) { .app-header.navbar { position: fixed !important; z-index: $zindex-sticky; width: 100%; text-align: center; background-color: $navbar-brand-bg; @include borders($navbar-brand-border); .navbar-toggler { @if (color.channel($navbar-brand-bg, 'lightness', $space: hsl) > 40%) { color: $navbar-color; } @else { color: #fff; } } .navbar-brand { position: absolute; left: 50%; margin-left: -(math.div($navbar-brand-width, 2)); } } .app-body { margin-top: $navbar-height; } .breadcrumb-fixed { .main:nth-child(2) .breadcrumb { right: auto; left: auto; width: 100%; } } .sidebar { position: fixed; z-index: $zindex-sticky - 1; width: $mobile-sidebar-width; height: calc(100vh - #{$navbar-height}); height: calc(100dvh - #{$navbar-height}); margin-left: -$mobile-sidebar-width; .sidebar-nav, .nav { width: $mobile-sidebar-width; min-height: calc(100vh - #{$navbar-height}); min-height: calc(100dvh - #{$navbar-height}); } .sidebar-minimizer { display: none; } } .main, .app-footer { margin-left: 0 !important; } // .aside-menu { // margin-right: - $aside-menu-width; // } .sidebar-hidden { .sidebar { margin-left: -$mobile-sidebar-width; } } .sidebar-mobile-show { .sidebar { width: $mobile-sidebar-width; margin-left: 0; } .main { margin-right: -$mobile-sidebar-width !important; margin-left: $mobile-sidebar-width !important; } } } ================================================ FILE: packages/server-admin-ui/scss/core/_loading.scss ================================================ // Angular Version // Make clicks pass-through // scss-lint:disable all #loading-bar, #loading-bar-spinner { -webkit-pointer-events: none; pointer-events: none; -moz-transition: 350ms linear all; -o-transition: 350ms linear all; -webkit-transition: 350ms linear all; transition: 350ms linear all; } #loading-bar.ng-enter, #loading-bar.ng-leave.ng-leave-active, #loading-bar-spinner.ng-enter, #loading-bar-spinner.ng-leave.ng-leave-active { opacity: 0; } #loading-bar.ng-enter.ng-enter-active, #loading-bar.ng-leave, #loading-bar-spinner.ng-enter.ng-enter-active, #loading-bar-spinner.ng-leave { opacity: 1; } #loading-bar .bar { position: fixed; top: 0; left: 0; z-index: 20002; width: 100%; height: 2px; background: theme-color('primary'); border-top-right-radius: 1px; border-bottom-right-radius: 1px; -moz-transition: width 350ms; -o-transition: width 350ms; -webkit-transition: width 350ms; transition: width 350ms; } // Fancy blur effect #loading-bar .peg { position: absolute; top: 0; right: 0; width: 70px; height: 2px; -moz-border-radius: 100%; -webkit-border-radius: 100%; border-radius: 100%; -moz-box-shadow: #29d 1px 0 6px 1px; -ms-box-shadow: #29d 1px 0 6px 1px; -webkit-box-shadow: #29d 1px 0 6px 1px; box-shadow: #29d 1px 0 6px 1px; opacity: 0.45; } #loading-bar-spinner { position: fixed; top: 10px; left: 10px; z-index: 10002; display: block; } #loading-bar-spinner .spinner-icon { width: 14px; height: 14px; border: solid 2px transparent; border-top-color: #29d; border-left-color: #29d; border-radius: 50%; -moz-animation: loading-bar-spinner 400ms linear infinite; -ms-animation: loading-bar-spinner 400ms linear infinite; -o-animation: loading-bar-spinner 400ms linear infinite; -webkit-animation: loading-bar-spinner 400ms linear infinite; animation: loading-bar-spinner 400ms linear infinite; } @-webkit-keyframes loading-bar-spinner { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } } @-moz-keyframes loading-bar-spinner { 0% { -moz-transform: rotate(0deg); transform: rotate(0deg); } 100% { -moz-transform: rotate(360deg); transform: rotate(360deg); } } @-o-keyframes loading-bar-spinner { 0% { -o-transform: rotate(0deg); transform: rotate(0deg); } 100% { -o-transform: rotate(360deg); transform: rotate(360deg); } } @-ms-keyframes loading-bar-spinner { 0% { -ms-transform: rotate(0deg); transform: rotate(0deg); } 100% { -ms-transform: rotate(360deg); transform: rotate(360deg); } } @keyframes loading-bar-spinner { 0% { transform: rotate(0deg); transform: rotate(0deg); } 100% { transform: rotate(360deg); transform: rotate(360deg); } } //Ajax & Static Version .pace { -webkit-pointer-events: none; pointer-events: none; -moz-user-select: none; -webkit-user-select: none; user-select: none; } .pace-inactive { display: none; } .pace .pace-progress { position: fixed; top: 0; right: 100%; z-index: 2000; width: 100%; height: 2px; background: theme-color('primary'); } ================================================ FILE: packages/server-admin-ui/scss/core/_mixins.scss ================================================ @use 'sass:color'; @use 'sass:list'; @use 'sass:map'; // Bootstrap 5 compatibility: hover mixins were removed in BS5 // Adding them back for backwards compatibility with CoreUI styles @mixin hover-focus { &:hover, &:focus { @content; } } @mixin hover { &:hover { @content; } } @mixin plain-hover-focus { &, &:hover, &:focus { @content; } } @mixin hover-focus-active { &:hover, &:focus, &:active { @content; } } @mixin button-social-size( $padding-y, $padding-x, $font-size, $line-height, $border-radius ) { padding: $padding-y $padding-x; font-size: $font-size; line-height: $line-height; border: 0; @include border-radius($border-radius); &::before { width: ($padding-y * 2) + ($font-size * $line-height); height: ($padding-y * 2) + ($font-size * $line-height); padding: $padding-y 0; font-size: $font-size; line-height: $line-height; @include border-radius($border-radius); } span { margin-left: ($padding-y * 2) + ($font-size * $line-height); } &.icon { width: ($padding-y * 2) + ($font-size * $line-height); height: ($padding-y * 2) + ($font-size * $line-height); } } @mixin avatar($width, $status-width) { position: relative; display: inline-block; width: $width; .img-avatar { width: $width; height: $width; } .avatar-status { position: absolute; right: 0; bottom: 0; display: block; width: $status-width; height: $status-width; border: 1px solid #fff; border-radius: 50em; } } @mixin borders($borders) { @each $border in $borders { $direction: list.nth($border, 1); @if $direction == 'all' { $size: map.get(map.get($borders, $direction), size); $style: map.get(map.get($borders, $direction), style); $color: map.get(map.get($borders, $direction), color); border: $size $style $color; } @else if $direction == 'top' { $size: map.get(map.get($borders, $direction), size); $style: map.get(map.get($borders, $direction), style); $color: map.get(map.get($borders, $direction), color); border-top: $size $style $color; } @else if $direction == 'right' { $size: map.get(map.get($borders, $direction), size); $style: map.get(map.get($borders, $direction), style); $color: map.get(map.get($borders, $direction), color); border-right: $size $style $color; } @else if $direction == 'bottom' { $size: map.get(map.get($borders, $direction), size); $style: map.get(map.get($borders, $direction), style); $color: map.get(map.get($borders, $direction), color); border-bottom: $size $style $color; } @else if $direction == 'left' { $size: map.get(map.get($borders, $direction), size); $style: map.get(map.get($borders, $direction), style); $color: map.get(map.get($borders, $direction), color); border-left: $size $style $color; } } } @mixin sidebar-width($borders, $width) { $sidebar-width: $width; @each $border in $borders { $direction: list.nth($border, 1); @if $direction == 'all' { $size: map.get(map.get($borders, $direction), size); $sidebar-width: ($sidebar-width - (2 * $size)); } @else if $direction == 'right' { $size: map.get(map.get($borders, $direction), size); $sidebar-width: $sidebar-width - $size; } @else if $direction == 'left' { $size: map.get(map.get($borders, $direction), size); $sidebar-width: $sidebar-width - $size; } width: $sidebar-width; } } @mixin bg-variant($parent, $color) { #{$parent} { @include border-radius( $card-border-radius-inner $card-border-radius-inner $card-border-radius-inner $card-border-radius-inner ); color: #fff !important; background-color: $color !important; } a#{$parent} { @include hover-focus { background-color: color.adjust($color, $lightness: -10%); } } } @mixin card-accent-variant($color) { border-top-width: 2px; border-top-color: $color; } ================================================ FILE: packages/server-admin-ui/scss/core/_mobile.scss ================================================ ================================================ FILE: packages/server-admin-ui/scss/core/_modal.scss ================================================ @each $color, $value in $theme-colors { .modal-#{$color} { .modal-content { border-color: $value; } .modal-header { color: #fff; background-color: $value; } } } ================================================ FILE: packages/server-admin-ui/scss/core/_nav.scss ================================================ .nav-tabs { .nav-link { color: $gray-600; &.active { color: $gray-800; background: #fff; border-color: $border-color; border-bottom-color: #fff; &:focus { background: #fff; border-color: $border-color; border-bottom-color: #fff; } } } } .tab-content { margin-top: -1px; background: #fff; border: 1px solid $border-color; .tab-pane { padding: $spacer; } } .card-block { .tab-content { margin-top: 0; border: 0; } } ================================================ FILE: packages/server-admin-ui/scss/core/_navbar.scss ================================================ .app-header.navbar { position: relative; flex-direction: row; height: $navbar-height; padding: 0; margin: 0; background-color: $navbar-bg; @include borders($navbar-border); .navbar-brand { display: inline-block; width: $navbar-brand-width; height: $navbar-height; padding: $navbar-padding-y $navbar-padding-x; margin-right: 0; background-color: $navbar-brand-bg; background-image: $navbar-brand-logo; background-repeat: no-repeat; background-position: center center; background-size: $navbar-brand-logo-size; @include borders($navbar-brand-border); } .navbar-toggler { min-width: 50px; padding: $navbar-toggler-padding-y 0; &:hover .navbar-toggler-icon { background-image: $navbar-toggler-icon-hover; } } .navbar-toggler-icon { height: 23px; background-image: $navbar-toggler-icon; } .navbar-nav { flex-direction: row; align-items: center; } .nav-item { position: relative; min-width: 50px; margin: 0 !important; text-align: center; button { margin: 0 auto; } .nav-link { padding-top: 0; padding-bottom: 0; background: 0; border: 0; .badge { position: absolute; top: 50%; left: 50%; margin-top: -16px; margin-left: 0; } > .img-avatar { height: $navbar-height - 20px; margin: 0 10px; } } } .dropdown-menu { padding-bottom: 0; line-height: $line-height-base; } .dropdown-item { min-width: 180px; } } .navbar-brand { color: $navbar-active-color; @include hover-focus { color: $navbar-active-color; } } .navbar-nav { .nav-link { color: $navbar-color; @include hover-focus { color: $navbar-hover-color; } } .open > .nav-link, .active > .nav-link, .nav-link.open, .nav-link.active { @include plain-hover-focus { color: $navbar-active-color; } } } .navbar-divider { background-color: rgba(0, 0, 0, 0.075); } @include media-breakpoint-up(lg) { .brand-minimized { .app-header.navbar { .navbar-brand { width: $navbar-brand-minimized-width; background-color: $navbar-brand-minimized-bg; background-image: $navbar-brand-minimized-logo; background-size: $navbar-brand-minimized-logo-size; @include borders($navbar-brand-minimized-border); } } } } ================================================ FILE: packages/server-admin-ui/scss/core/_others.scss ================================================ // scss-lint:disable QualifyingElement hr.transparent { border-top: 1px solid transparent; } ================================================ FILE: packages/server-admin-ui/scss/core/_progress.scss ================================================ .progress-xs { height: 4px; } .progress-sm { height: 8px; } // White progress bar .progress-white { background-color: rgba(255, 255, 255, 0.2) !important; .progress-bar { background-color: #fff; } } ================================================ FILE: packages/server-admin-ui/scss/core/_rtl.scss ================================================ @use 'sass:math'; // // RTL Support // // scss-lint:disable NestingDepth, SelectorDepth *[dir='rtl'] { direction: rtl; unicode-bidi: embed; ul { -webkit-padding-start: 0; } table tr th { text-align: right; } // Breadcrumb .breadcrumb-item { float: right; } .breadcrumb-menu { right: auto; left: $breadcrumb-padding-x; } // Dropdown .dropdown-item { text-align: right; i { margin-right: -10px; margin-left: 10px; } .badge { right: auto; left: 10px; } } // // Sidebar // .sidebar-hidden { .sidebar { margin-right: -$sidebar-width; } } .sidebar-fixed { .main, .app-footer { margin-right: $sidebar-width; } &.sidebar-hidden { .main, .app-footer { margin-right: 0; } } } .sidebar-minimized { .sidebar { flex: 0 0 $sidebar-minimized-width; } &.sidebar-hidden { .sidebar { margin-right: -$sidebar-minimized-width; margin-left: 0; } } &.sidebar-fixed { .main, .app-footer { margin-right: $sidebar-minimized-width; } &.sidebar-hidden { .main, .app-footer { margin-left: 0; } } } } // // Aside Menu // .aside-menu-hidden { .aside-menu { margin-right: 0; margin-left: -$aside-menu-width; } } .aside-menu-fixed { .aside-menu { right: auto; left: 0; } .main, .app-footer { //margin-right: 0; margin-left: $aside-menu-width; } &.aside-menu-hidden { .main, .app-footer { margin-left: 0; } } } .aside-menu-off-canvas { .aside-menu { position: fixed; right: 0; z-index: $zindex-sticky - 1; height: 100%; .tab-content { height: calc( 100vh - #{$aside-menu-nav-padding-y * 2 + $font-size-base} - #{$navbar-height} ); } } } // Sidebar Menu .sidebar { .sidebar-nav { .nav { .nav-item { .nav-link { direction: rtl; i { margin: 0 0 0 math.div($sidebar-nav-link-padding-x, 2); } .badge { float: left; margin-top: 2px; // margin-left: 10px; } &.nav-dropdown-toggle { &::before { position: absolute; right: auto !important; left: $sidebar-nav-link-padding-x; transform: rotate(180deg); } } } &.nav-dropdown { &.open { > .nav-link.nav-dropdown-toggle::before { transform: rotate(270deg); } } } } } } } .sidebar-minimized .sidebar { .nav-link { padding-right: 0; i { float: right; padding: 0; margin: 0; } .badge { right: auto; left: 15px; } } .nav > .nav-dropdown { &:hover { > .nav-dropdown-items { right: $sidebar-minimized-width; left: 0; } } } } // Horizontal bars .horizontal-bars { li { .bars { padding-right: 100px; padding-left: 0; .progress:first-child { margin-bottom: 2px; } } } &.type-2 { li { i { margin-right: 5px; margin-left: $spacer; } .value { float: left; font-weight: 600; } .bars { padding: 0; } } } } // Icon list .icons-list { li { position: relative; height: 40px; vertical-align: middle; i { float: right; } .desc { margin-right: 50px; margin-left: 0; } .value { right: auto; left: 45px; text-align: left; strong { display: block; margin-top: -3px; } } .actions { right: auto; left: 10px; } } } // Callouts .callout { border: 0 solid $border-color; border-right-width: 0.25rem; @each $color, $value in $theme-colors { &.callout-#{$color} { border-right-color: $value; } } .chart-wrapper { left: 0; float: left; } } .callout-default { border-right-color: $text-muted; } } ================================================ FILE: packages/server-admin-ui/scss/core/_sidebar.scss ================================================ @use 'sass:math'; @use 'sass:color'; // scss-lint:disable NestingDepth, SelectorDepth .sidebar { display: flex; flex-direction: column; padding: $sidebar-padding; color: $sidebar-color; background: $sidebar-bg; @include borders($sidebar-borders); .sidebar-close { position: absolute; right: 0; display: none; padding: 0 $spacer; font-size: 24px; font-weight: 800; line-height: $navbar-height; color: $sidebar-color; background: 0; border: 0; opacity: 0.8; &:hover { opacity: 1; } } // Will be added soon // .sidebar-brand { } .sidebar-header { flex: 0 0 $sidebar-header-height; padding: $sidebar-header-padding-y $sidebar-header-padding-x; text-align: center; background: $sidebar-header-bg; } .sidebar-form .form-control { color: $sidebar-form-color; background: $sidebar-form-bg; border: $sidebar-form-border; &::placeholder { color: $sidebar-form-placeholder-color; } } .sidebar-nav { position: relative; flex: 1; overflow-x: hidden; overflow-y: auto; -ms-overflow-style: -ms-autohiding-scrollbar; @include sidebar-width($sidebar-borders, $sidebar-width); &::-webkit-scrollbar { position: absolute; width: 10px; margin-left: -10px; -webkit-appearance: none; } &::-webkit-scrollbar-track { background-color: color.adjust($sidebar-bg, $lightness: 5%); border-right: 1px solid color.adjust($sidebar-bg, $lightness: -5%); border-left: 1px solid color.adjust($sidebar-bg, $lightness: -5%); } &::-webkit-scrollbar-thumb { height: 50px; background-color: color.adjust($sidebar-bg, $lightness: -10%); background-clip: content-box; border-color: transparent; border-style: solid; border-width: 1px 2px; } } .nav { @include sidebar-width($sidebar-borders, $sidebar-width); flex-direction: column; min-height: 100%; } .nav-title { padding: $sidebar-nav-title-padding-y $sidebar-nav-title-padding-x; font-size: 11px; font-weight: 600; color: $sidebar-nav-title-color; text-transform: uppercase; } .nav-divider, .divider { height: 10px; } .nav-item { position: relative; margin: 0; transition: background 0.3s ease-in-out; } .nav-dropdown-items { max-height: 0; padding: 0; margin: 0; overflow-y: hidden; transition: max-height 0.3s ease-in-out; .nav-item { padding: 0; list-style: none; } } .nav-link { display: block; padding: $sidebar-nav-link-padding-y $sidebar-nav-link-padding-x; color: $sidebar-nav-link-color; text-decoration: none; background: $sidebar-nav-link-bg; @include borders($sidebar-nav-link-borders); @if $enable-sidebar-nav-rounded { border-radius: $border-radius; } i { display: inline-block; width: 20px; margin: 0 math.div($sidebar-nav-link-padding-x, 2) 0 0; font-size: 14px; color: $sidebar-nav-link-icon-color; text-align: center; } .badge { float: right; margin-top: 2px; } &.active { color: $sidebar-nav-link-active-color; background: $sidebar-nav-link-active-bg; @include borders($sidebar-nav-link-active-borders); i { color: $sidebar-nav-link-active-icon-color; } } &:hover { color: $sidebar-nav-link-hover-color; background: $sidebar-nav-link-hover-bg; @include borders($sidebar-nav-link-hover-borders); i { color: $sidebar-nav-link-hover-icon-color; } &.nav-dropdown-toggle::before { background-image: $sidebar-nav-dropdown-indicator-hover; } } @each $color, $value in $theme-colors { &.nav-link-#{$color} { background: $value; i { color: rgba(255, 255, 255, 0.7); } &:hover { background: color.adjust($value, $lightness: -5%) !important; i { color: #fff; } } } } } // ex. Components .nav-dropdown-toggle { position: relative; &::before { position: absolute; top: 50%; right: $sidebar-nav-link-padding-x; display: block; width: 8px; height: 8px; padding: 0; margin-top: -4px; content: ''; background-image: $sidebar-nav-dropdown-indicator; background-repeat: no-repeat; background-position: center; transition: transform 0.3s; } } // ex.
  • {wrapper(titleItem)}{' '}
  • ) } const divider = (dividerItem: NavItemData, key: number): ReactNode => { const classes = classNames('divider', dividerItem.class) return
  • } const renderIcon = (iconClass?: string): ReactNode => { if (!iconClass) return null return } const navLink = ( item: NavItemData, key: number, classes: { item: string; link: string; icon: string } ): ReactNode => { const url = item.url ? item.url : '' const isExternal = (url: string) => { const link = url ? url.substring(0, 4) : '' return link === 'http' } return ( {isExternal(url) ? ( {renderIcon(item.icon)} {item.name} {badges(item)} ) : ( isActive ? `${classes.link} active` : classes.link } {...(item.props || {})} > {renderIcon(item.icon)} {item.name} {badges(item)} )} ) } const navItem = (item: NavItemData, key: number): ReactNode => { const classes = { item: classNames(item.class), link: classNames( 'nav-link', item.variant ? `nav-link-${item.variant}` : '' ), icon: classNames(item.icon) } return navLink(item, key, classes) } const navDropdown = (item: NavItemData, key: number): ReactNode => { return (
  • {renderIcon(item.icon)} {item.name} {badges(item)}
      {navList(item.children || [])}
  • ) } const navType = (item: NavItemData, idx: number): ReactNode => item.title ? title(item, idx) : item.divider ? divider(item, idx) : item.children ? navDropdown(item, idx) : navItem(item, idx) const navList = (navItems: NavItemData[]): ReactNode[] => { return navItems.map((item, index) => navType(item, index)) } return (
    ) } ================================================ FILE: packages/server-admin-ui/src/components/SidebarFooter/SidebarFooter.tsx ================================================ export default function SidebarFooter() { return null } ================================================ FILE: packages/server-admin-ui/src/components/SidebarForm/SidebarForm.tsx ================================================ export default function SidebarForm() { return null } ================================================ FILE: packages/server-admin-ui/src/components/SidebarHeader/SidebarHeader.tsx ================================================ export default function SidebarHeader() { return null } ================================================ FILE: packages/server-admin-ui/src/components/SidebarMinimizer/SidebarMinimizer.tsx ================================================ import { useCallback } from 'react' export default function SidebarMinimizer() { const handleClick = useCallback(() => { document.body.classList.toggle('sidebar-minimized') document.body.classList.toggle('brand-minimized') }, []) return ( )} {isEditing && ( )} {isExpanded && ( {currentValue !== undefined && typeof currentValue === 'number' && (
    Value: {formatMetaValue(currentValue)}{' '} {siUnit && {siUnit}} {converted && ( → {formatMetaValue(converted.value)}{' '} {converted.unit} )}
    )}
    e.preventDefault()} > {metaValues .filter(({ key }) => key !== 'zones') .map(({ key, value }) => { const renderer = METAFIELDRENDERERS[key] if (renderer) { const props: MetaFormRowProps = { fieldKey: key, value, disabled: !isEditing, categories: filteredCategories, siUnit, unitDefinitions, setValue: (metaFieldValue) => setLocalMeta({ ...localMeta, [key]: metaFieldValue }), setKey: (metaFieldKey) => { const copy = { ...localMeta } copy[metaFieldKey] = localMeta[key] delete copy[key] setLocalMeta(copy) }, deleteKey: () => { const copy = { ...localMeta } delete copy[key] setLocalMeta(copy) }, renderValue: () => <>, idPrefix } return ( {renderer(props)} ) } else { return ( ) } })} {isEditing && ( )} setLocalMeta({ ...localMeta, zones: newZones }) } idPrefix={idPrefix} siUnit={siUnit} category={category} presetDetails={presetDetails} unitDefinitions={unitDefinitions} />
    )} ) } const MetaFormRow: React.FC = (props) => { const { fieldKey, renderValue: V, disabled, setKey, deleteKey, description, idPrefix } = props const fieldSelectId = `${idPrefix}-field-${fieldKey}` const valueInputId = `${idPrefix}-value-${fieldKey}` return ( setKey(e.target.value)} > {METAFIELDS.filter((fieldName) => fieldName !== 'zones').map( (fieldName) => ( ) )} {description && ( {description} )} {!disabled && ( )} ) } interface UnknownMetaFormRowProps { metaKey: string value: unknown } const UnknownMetaFormRow: React.FC = ({ metaKey, value }) => { return ( {metaKey}
              {JSON.stringify(value, null, 2)}
            
    ) } interface ZoneProps { zone: Zone isEditing: boolean showHint: boolean setZone: (zone: Zone) => void deleteZone: () => void moveUp: () => void moveDown: () => void canMoveUp: boolean canMoveDown: boolean idPrefix: string index: number displayUnit: string siUnit: string unitDefinitions: UnitDefinitions | null } const formatZoneValue = (v: number | null | undefined): string => { if (v === null || v === undefined) return '' return Number.isInteger(v) ? String(v) : v.toFixed(2) } const ZoneRow: React.FC = ({ zone, isEditing, showHint, setZone, deleteZone, moveUp, moveDown, canMoveUp, canMoveDown, idPrefix, index, displayUnit, siUnit, unitDefinitions }) => { const { state, lower, upper, message } = zone const zoneId = `${idPrefix}-zone-${index}` const hasConversion = displayUnit && displayUnit !== siUnit const displayLower = hasConversion ? convertFromSI(lower, siUnit, displayUnit, unitDefinitions) : lower const displayUpper = hasConversion ? convertFromSI(upper, siUnit, displayUnit, unitDefinitions) : upper const onLowerChange = (e: React.ChangeEvent) => { const val = Number(e.target.value) if (hasConversion) { const si = convertToSI(val, siUnit, displayUnit, unitDefinitions) if (si !== null) setZone({ ...zone, lower: si }) } else { setZone({ ...zone, lower: val }) } } const onUpperChange = (e: React.ChangeEvent) => { const val = Number(e.target.value) if (hasConversion) { const si = convertToSI(val, siUnit, displayUnit, unitDefinitions) if (si !== null) setZone({ ...zone, upper: si }) } else { setZone({ ...zone, upper: val }) } } return (
    {showHint && ( Lower )} {showHint && ( Upper )} {showHint && ( State )} setZone({ ...zone, state: e.target.value })} > {STATES.map((s) => ( ))} {showHint && ( Message )} setZone({ ...zone, message: e.target.value })} value={message} /> {isEditing && ( )}
    ) } interface ZonesProps { zones: Zone[] isEditing: boolean setZones: (zones: Zone[]) => void idPrefix: string siUnit: string category: string | undefined presetDetails: ReturnType unitDefinitions: UnitDefinitions | null } function Zones({ zones, isEditing, setZones, idPrefix, siUnit, category, presetDetails, unitDefinitions }: ZonesProps) { const availableUnits = getAvailableUnits(siUnit, unitDefinitions) // Default display unit: preset's target unit for the category, or SI unit const presetTargetUnit = category ? (presetDetails?.categories?.[category]?.targetUnit ?? '') : '' const defaultUnit = presetTargetUnit && availableUnits.some((u) => u.unit === presetTargetUnit) ? presetTargetUnit : siUnit const [displayUnit, setDisplayUnit] = useState(defaultUnit) const prevDefaultUnit = useRef(defaultUnit) if (prevDefaultUnit.current !== defaultUnit) { prevDefaultUnit.current = defaultUnit setDisplayUnit(defaultUnit) } const displaySymbol = availableUnits.find((u) => u.unit === displayUnit)?.symbol || displayUnit const [zoneIds, setZoneIds] = useState(() => zones.map(() => generateZoneId()) ) const expectedLength = zones.length if (zoneIds.length !== expectedLength) { if (expectedLength > zoneIds.length) { const newIds = [...zoneIds] while (newIds.length < expectedLength) { newIds.push(generateZoneId()) } setZoneIds(newIds) } else { setZoneIds(zoneIds.slice(0, expectedLength)) } } const moveZone = (fromIndex: number, toIndex: number) => { const newZones = [...zones] const [moved] = newZones.splice(fromIndex, 1) newZones.splice(toIndex, 0, moved) const newIds = [...zoneIds] const [movedId] = newIds.splice(fromIndex, 1) newIds.splice(toIndex, 0, movedId) setZoneIds(newIds) setZones(newZones) } return (
    Zones Alert thresholds {availableUnits.length > 1 && (
    Unit: setDisplayUnit(e.target.value)} > {availableUnits.map((u) => ( ))}
    )} {(zones === undefined || zones.length === 0) && !isEditing && ( No zones defined )} {zones.map((zone, i) => ( { const newZones = [...zones] newZones[i] = newZone setZones(newZones) }} deleteZone={() => { setZoneIds((prev) => [ ...prev.slice(0, i), ...prev.slice(i + 1) ]) const newZones = zones.filter((_, index) => index !== i) setZones(newZones) }} moveUp={() => moveZone(i, i - 1)} moveDown={() => moveZone(i, i + 1)} canMoveUp={i > 0} canMoveDown={i < zones.length - 1} idPrefix={idPrefix} index={i} displayUnit={displayUnit} siUnit={siUnit} unitDefinitions={unitDefinitions} /> ))} {isEditing && ( )}
    ) } export default Meta ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/TimestampCell.tsx ================================================ interface TimestampCellProps { timestamp: string isPaused: boolean className?: string } // TimestampCell triggers a CSS animation when the timestamp changes. // We use the timestamp string itself as the animation key to trigger re-animation. // The CSS animation class is always applied when not paused - the key change // triggers the animation restart. function TimestampCell({ timestamp, isPaused, className }: TimestampCellProps) { // Use timestamp as animation key - when it changes, React remounts the element // which restarts the CSS animation. When paused, use static key. const animationKey = isPaused ? 'paused' : timestamp const cellClass = `virtual-table-cell timestamp-cell ${className || ''} ${ !isPaused ? 'timestamp-updated' : '' }` return (
    {timestamp}
    ) } export default TimestampCell ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/ValueRenderers.tsx ================================================ import { Suspense, ComponentType } from 'react' import { toLazyDynamicComponent } from '../Webapps/dynamicutilities' import parse from 'html-react-parser' import { faEye } from '@fortawesome/free-solid-svg-icons/faEye' import { faEyeSlash } from '@fortawesome/free-solid-svg-icons/faEyeSlash' import { faBell } from '@fortawesome/free-solid-svg-icons/faBell' import { faBellSlash } from '@fortawesome/free-solid-svg-icons/faBellSlash' import '../../blinking-circle.css' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import type { MetaData } from '../../store' interface RendererProps { value: unknown units?: string convertedValue?: number | null convertedUnit?: string | null [key: string]: unknown } interface HTMLRendererProps { value: unknown html: string } interface DirectionRendererProps { value: number size?: string } interface AttitudeValue { pitch?: number roll?: number } interface AttitudeRendererProps { value: AttitudeValue size?: string } interface NotificationValue { message?: string state?: string method?: string[] } interface NotificationRendererProps { value: NotificationValue | null | undefined } interface LargeArrayRendererProps { value: unknown } interface MeterRendererProps { value: number min?: number max?: number low?: number high?: number optimum?: number pct?: boolean precision?: number } interface PositionValue { longitude?: number latitude?: number } interface PositionRendererProps { value: PositionValue | null | undefined } interface Satellite { id: number | string elevation: number azimuth: number SNR?: number } interface SatellitesInViewValue { count?: number satellites?: Satellite[] } interface SatellitesInViewRendererProps { value: SatellitesInViewValue | null | undefined } function radiansToDegrees(radians: number): number { return radians * (180 / Math.PI) } const SimpleHTMLRenderer = ({ value, html }: HTMLRendererProps) => { const h = html.replaceAll('{{value}}', String(value)) return
    {parse(h)}
    } const DirectionRenderer = ({ value, size = '1em' }: DirectionRendererProps) => { const traditionalCompassPoints = [ 'N', 'N by E', 'NNE', 'NE by N', 'NE', 'NE by E', 'ENE', 'E by N', 'E', 'E by S', 'ESE', 'SE by E', 'SE', 'SE by S', 'SSE', 'S by E', 'S', 'S by W', 'SSW', 'SW by S', 'SW', 'SW by W', 'WSW', 'W by S', 'W', 'W by N', 'WNW', 'NW by W', 'NW', 'NW by N', 'NNW', 'N by W' ] const directionDegrees = radiansToDegrees(value) const compassPoint = traditionalCompassPoints[ Math.round((((directionDegrees % 360) + 360) % 360) / 11.25) % 32 ] const arrowStyle: React.CSSProperties = { fontSize: size, fontWeight: 'bold', transition: 'transform 0.3s ease-out', transform: `rotate(${directionDegrees}deg) translateY(-2px)`, display: 'inline-block' // Required for rotation to work reliably } return (
    {directionDegrees.toFixed(2)}° {compassPoint}
    ) } const AttitudeRenderer = ({ value, size = '2em' }: AttitudeRendererProps) => { const pitch = radiansToDegrees(value.pitch || 0) const roll = radiansToDegrees(value.roll || 0) const horizonHeight = ((pitch + 90) / 180) * 100 + '%' const attitudeText = `pitch: ${pitch.toFixed(1)}° roll: ${roll.toFixed(1)}°` return (
    {attitudeText}
    ) } const NotificationRenderer = ({ value }: NotificationRendererProps) => { const { message, state, method = [] } = value ? value : {} const severityColor = { info: 'green', normal: 'green', nominal: 'green', warn: 'yellow', alert: 'orange', alarm: 'red', emergency: 'darkred' }[state as string] || 'gray' const circleStyle: React.CSSProperties = { width: '1em', height: '1em', borderRadius: '50%', backgroundColor: severityColor, display: 'inline-block', marginLeft: '.5em' } return (
    {state === 'emergency' ? ( ) : ( )} {(state ? state.toUpperCase() : 'undefined') + ': ' + message}
    ) } const LargeArrayRenderer = ({ value }: LargeArrayRendererProps) => { if (!Array.isArray(value) || value.length <= 1) { return {JSON.stringify(value)} } return (
    {JSON.stringify(value[0])} 1 of {value.length} {JSON.stringify(value)}
    ) } const MeterRenderer = ({ value, min = 0, max = 1, low = 0.5, high = 1.01, optimum = 1, pct = true, precision = 2 }: MeterRendererProps) => { const txt = (value * (pct ? 100 : 1)).toFixed(precision) + (pct ? '%' : '') return (
    {value}% {' '} {txt}
    ) } const PositionRenderer = ({ value }: PositionRendererProps) => { if (!value || typeof value !== 'object') { return {JSON.stringify(value)} } const { longitude, latitude } = value if (typeof longitude !== 'number' || typeof latitude !== 'number') { return {JSON.stringify(value)} } return ( longitude: {longitude}, latitude: {latitude} ) } const SatellitesInViewRenderer = ({ value }: SatellitesInViewRendererProps) => { if (!value || typeof value !== 'object' || !Array.isArray(value.satellites)) { return {JSON.stringify(value)} } const { count, satellites } = value const size = 200 const center = size / 2 const maxRadius = center - 20 const getSNRColor = (snr: number | undefined): string => { if (!snr || snr <= 0) return '#000' if (snr >= 40) return '#28a745' if (snr >= 30) return '#004085' return '#8b0000' } const polarToCartesian = ( elevation: number, azimuth: number ): { x: number; y: number } => { const elevationRadius = maxRadius * (1 - elevation / (Math.PI / 2)) const x = center + elevationRadius * Math.sin(azimuth) const y = center - elevationRadius * Math.cos(azimuth) return { x, y } } return (
    {/* Elevation circles (30° intervals) */} {/* Cardinal direction lines */} {/* Direction labels */} N S W E {/* Elevation angle labels */} 30° 60° 90° {/* Satellites */} {satellites.map((sat) => { const { x, y } = polarToCartesian(sat.elevation, sat.azimuth) const color = getSNRColor(sat.SNR) const hasSignal = sat.SNR && sat.SNR > 0 const snrText = sat.SNR ? `${sat.SNR} dB` : 'No signal' return ( Satellite {sat.id}: {snrText} {sat.id} ) })} {/* Info and Legend */}
    Satellites in view: {count}
    ≥40 dB
    30-40 dB
    0-30 dB
    No signal
    ) } type RendererComponent = ComponentType function createLazySuspenseWrapper( LazyComponent: ComponentType ): RendererComponent { // Define the component function outside of render to satisfy react-hooks/static-components const SuspenseWrapper: RendererComponent = (props: RendererProps) => (
    }>
    ) return SuspenseWrapper } const Renderers: Record = { Position: PositionRenderer as RendererComponent, SatellitesInView: SatellitesInViewRenderer as RendererComponent, Meter: MeterRenderer as RendererComponent, SimpleHTML: SimpleHTMLRenderer as unknown as RendererComponent, LargeArray: LargeArrayRenderer as RendererComponent, Notification: NotificationRenderer as RendererComponent, Attitude: AttitudeRenderer as RendererComponent, Direction: DirectionRenderer as RendererComponent } const VALUE_RENDERERS: Record = { 'navigation.position': Renderers.Position, 'navigation.gnss.satellitesInView': Renderers.SatellitesInView } export const getValueRenderer = ( path: string, meta: MetaData | null ): RendererComponent | null => { if (path.startsWith('notifications.')) { return NotificationRenderer as RendererComponent } if (meta && meta.renderer && meta.renderer.module && meta.renderer.name) { const cacheKey = `${meta.renderer.module}.${meta.renderer.name}` if (Renderers[cacheKey]) { return Renderers[cacheKey] } else { const LazyRenderer = toLazyDynamicComponent( meta.renderer.module, meta.renderer.name ) as ComponentType const comp = createLazySuspenseWrapper(LazyRenderer) Renderers[cacheKey] = comp return comp } } if (meta && meta.renderer && meta.renderer.name) { return Renderers[meta.renderer.name] || null } if (meta && meta.units === 'ratio') { return MeterRenderer as RendererComponent } if (VALUE_RENDERERS[path]) { return VALUE_RENDERERS[path] } return null } export const DefaultValueRenderer = ({ value, units, convertedValue, convertedUnit }: RendererProps) => { let formattedValue = JSON.stringify( value, null, typeof value === 'object' && Object.keys(value || {}).length > 1 ? 2 : 0 ) if (typeof value === 'number') { formattedValue = Number.isInteger(value) ? value.toString() : value.toFixed(2) } let formattedConverted: string | null = null if ( convertedValue !== null && convertedValue !== undefined && typeof convertedValue === 'number' ) { formattedConverted = Number.isInteger(convertedValue) ? convertedValue.toString() : convertedValue.toFixed(2) } return ( <> {typeof value === 'object' ? (
    {formattedValue}
    ) : ( {formattedValue} {typeof value === 'number' && units && {units}} {formattedConverted && convertedUnit && ( ({formattedConverted} {convertedUnit}) )} )} ) } ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/VirtualTable.css ================================================ /* Virtual Table - CSS Grid layout for virtualization */ /* Uses Bootstrap 5 CSS custom properties for consistency */ .virtual-table { width: 100%; border: 1px solid var(--bs-border-color, #c2cfd6); border-radius: 0.25rem; background-color: var(--bs-body-bg, #fff); contain: layout style; } .virtual-table-header { display: grid; grid-template-columns: minmax(150px, 1.5fr) minmax(120px, 2fr) minmax(80px, 0.8fr) minmax(120px, 1.2fr); background-color: var(--bs-tertiary-bg, #f0f3f5); border-bottom: 2px solid var(--bs-border-color, #c2cfd6); position: sticky; top: 55px; /* Fixed header height */ z-index: 1; /* Low z-index to allow dropdowns to appear above */ } .virtual-table-header-cell { padding: 0.5rem 0.25rem; font-size: 0.8rem; font-weight: 600; border-right: 1px solid var(--bs-border-color, #c2cfd6); } .virtual-table-header-cell:last-child { border-right: none; } /* 5-column layout when context column is shown */ .virtual-table[data-show-context='true'] .virtual-table-header, .virtual-table[data-show-context='true'] .virtual-table-row { grid-template-columns: minmax(150px, 1.5fr) minmax(80px, 1fr) minmax(120px, 2fr) minmax(80px, 0.8fr) minmax(120px, 1.2fr); } .virtual-table-cell.context-cell { min-width: 80px; } .virtual-table-body { position: relative; } .virtual-table-row { display: grid; grid-template-columns: minmax(150px, 1.5fr) minmax(120px, 2fr) minmax(80px, 0.8fr) minmax(120px, 1.2fr); border-bottom: 1px solid var(--bs-border-color, #c2cfd6); font-size: 0.875rem; contain: layout paint style; } .virtual-table-row[data-raw-row='true'] { content-visibility: auto; contain-intrinsic-block-size: 150px; } .virtual-table-row:hover { background-color: var(--bs-tertiary-bg, #f0f3f5); } .virtual-table-row.striped { background-color: var(--bs-tertiary-bg, #f0f3f5); } .virtual-table-row.striped:hover { background-color: var(--bs-secondary-bg, #e2e6ea); } .virtual-table-cell { padding: 0.5rem 0.25rem; vertical-align: top; word-wrap: break-word; word-break: break-word; border-right: 1px solid var(--bs-border-color, #c2cfd6); overflow: hidden; } .virtual-table-cell:last-child { border-right: none; } .virtual-table-cell.path-cell { min-width: 150px; } .virtual-table-cell.value-cell { min-width: 120px; } .virtual-table-cell.timestamp-cell { min-width: 80px; white-space: nowrap; } .virtual-table-cell.source-cell { min-width: 120px; } /* Timestamp animation */ .virtual-table-cell.timestamp-updated { position: relative; } .virtual-table-cell.timestamp-updated::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background-color: var(--bs-success, #00cd79); animation: highlightFade 15s ease-out forwards; } @keyframes highlightFade { 0% { opacity: 1; } 100% { opacity: 0; } } /* Pre formatting for JSON values */ .virtual-table-cell pre { margin: 0; padding: 0; font-size: 0.8rem; white-space: pre-wrap; word-wrap: break-word; word-break: break-word; } /* Responsive breakpoints */ @media (max-width: 1200px) { .virtual-table-row, .virtual-table-header { font-size: 0.8rem; } .virtual-table-cell, .virtual-table-header-cell { padding: 0.4rem 0.2rem; } } @media (max-width: 992px) { .virtual-table-header, .virtual-table-row { grid-template-columns: minmax(120px, 1.5fr) minmax(100px, 2fr) minmax(70px, 0.8fr) minmax(100px, 1.2fr); } .virtual-table[data-show-context='true'] .virtual-table-header, .virtual-table[data-show-context='true'] .virtual-table-row { grid-template-columns: minmax(120px, 1.5fr) minmax(70px, 1fr) minmax(100px, 2fr) minmax(70px, 0.8fr) minmax(100px, 1.2fr); } } /* Narrow screens: stack cells vertically with per-row labels */ @media (max-width: 768px) { .virtual-table-header { display: none; } .virtual-table-row { display: block; padding: 0.5rem; border-bottom: 2px solid var(--bs-border-color, #c2cfd6); } .virtual-table-row.striped { background-color: var(--bs-tertiary-bg, #f0f3f5); } .virtual-table-cell { display: flex; flex-wrap: wrap; align-items: baseline; width: 100%; border-right: none; padding: 0.25rem 0; overflow: visible; min-width: 0; } .virtual-table-cell::before { content: attr(data-label); font-weight: 600; flex-shrink: 0; width: 60px; color: var(--bs-secondary-color, #6c757d); font-size: 0.75rem; } .virtual-table-cell.path-cell { display: block; font-weight: 600; font-size: 0.85rem; padding-bottom: 0.35rem; border-bottom: 1px solid var(--bs-border-color-translucent, #e9ecef); margin-bottom: 0.25rem; word-break: break-all; } .virtual-table-cell.path-cell::before { display: none; } .virtual-table-cell.value-cell { min-width: 0; } .virtual-table-cell.value-cell > * { flex: 1; min-width: 0; word-break: break-word; } .virtual-table-cell.timestamp-cell { white-space: normal; } .virtual-table-cell.timestamp-updated { position: static; } .virtual-table-cell.timestamp-updated::before { content: attr(data-label); position: static; width: 60px; background-color: transparent; animation: none; } .virtual-table-row:has(.timestamp-updated) { border-left: 3px solid var(--bs-success, #00cd79); animation: borderFade 15s ease-out forwards; } @keyframes borderFade { 0% { border-left-color: var(--bs-success, #00cd79); } 100% { border-left-color: transparent; } } .virtual-table-cell.source-cell { flex-wrap: wrap; } .virtual-table-cell.source-cell > * { word-break: break-all; } .virtual-table-info { font-size: 0.7rem; padding: 0.4rem; } } /* Row count indicator */ .virtual-table-info { padding: 0.5rem; font-size: 0.8rem; color: var(--bs-secondary-color, #6c757d); background-color: var(--bs-tertiary-bg, #f0f3f5); border-top: 1px solid var(--bs-border-color, #c2cfd6); } .copy-icon { display: inline-block; width: 12px; height: 14px; border: 1px solid currentColor; border-radius: 2px; position: relative; opacity: 0.5; margin-left: 4px; cursor: pointer; vertical-align: middle; } .copy-icon::before { content: ''; position: absolute; width: 10px; height: 12px; border: 1px solid currentColor; border-radius: 2px; top: -4px; left: -4px; background: var(--bs-body-bg, #fff); } .path-cell:hover .copy-icon, .source-cell:hover .copy-icon, .copy-icon:hover { opacity: 1; } .virtual-table-meta-row { content-visibility: auto; contain-intrinsic-block-size: 200px; } ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/VirtualizedDataTable.tsx ================================================ import { useRef, useEffect, useState, useCallback, useMemo, ChangeEvent } from 'react' import DataRow from './DataRow' import granularSubscriptionManager from './GranularSubscriptionManager' import './VirtualTable.css' interface VisibleItem { index: number path$SourceKey: string } interface VirtualizedDataTableProps { path$SourceKeys: string[] context: string raw: boolean isPaused: boolean onToggleSource: (source: string) => void selectedSources: Set onToggleSourceFilter: (event: ChangeEvent) => void sourceFilterActive: boolean showContext: boolean } function VirtualizedDataTable({ path$SourceKeys, context, raw, isPaused, onToggleSource, selectedSources, onToggleSourceFilter, sourceFilterActive, showContext }: VirtualizedDataTableProps) { const containerRef = useRef(null) const [isNarrowScreen, setIsNarrowScreen] = useState( typeof window !== 'undefined' && window.innerWidth <= 768 ) const rowHeight = raw ? 150 : isNarrowScreen ? 120 : 40 const overscan = raw ? 5 : isNarrowScreen ? 5 : 15 const computeInitialRange = useCallback(() => { const visibleCount = Math.ceil(window.innerHeight / rowHeight) + overscan * 2 return { start: 0, end: Math.min(path$SourceKeys.length - 1, visibleCount) } }, [path$SourceKeys.length, rowHeight, overscan]) const [visibleRange, setVisibleRange] = useState(computeInitialRange) useEffect(() => { const checkWidth = () => setIsNarrowScreen(window.innerWidth <= 768) window.addEventListener('resize', checkWidth) return () => window.removeEventListener('resize', checkWidth) }, []) const computeVisibleRange = useCallback(() => { if (!containerRef.current) return null const rect = containerRef.current.getBoundingClientRect() const containerTop = rect.top const viewportHeight = window.innerHeight let startOffset = 0 if (containerTop < 0) { startOffset = Math.abs(containerTop) } const startIndex = Math.max( 0, Math.floor(startOffset / rowHeight) - overscan ) const visibleCount = Math.ceil(viewportHeight / rowHeight) + overscan * 2 const endIndex = Math.min( path$SourceKeys.length - 1, startIndex + visibleCount ) return { start: startIndex, end: endIndex, visibleCount } }, [path$SourceKeys.length, rowHeight, overscan]) useEffect(() => { let ticking = false const handleScroll = () => { if (!ticking) { window.requestAnimationFrame(() => { const computed = computeVisibleRange() if (computed) { setVisibleRange((prev) => { const atStart = computed.start === 0 const atEnd = computed.end >= path$SourceKeys.length - 1 const significantChange = Math.abs(prev.start - computed.start) > 2 || Math.abs(prev.end - computed.end) > 2 const listGrew = prev.end < computed.end && prev.end === prev.start + computed.visibleCount - 1 if (atStart || atEnd || significantChange || listGrew) { return { start: computed.start, end: computed.end } } return prev }) } ticking = false }) ticking = true } } handleScroll() window.addEventListener('scroll', handleScroll, { passive: true }) window.addEventListener('resize', handleScroll, { passive: true }) return () => { window.removeEventListener('scroll', handleScroll) window.removeEventListener('resize', handleScroll) } }, [computeVisibleRange, path$SourceKeys.length]) const spacerBeforeHeight = raw ? 0 : visibleRange.start * rowHeight const spacerAfterHeight = raw ? 0 : Math.max(0, (path$SourceKeys.length - visibleRange.end - 1) * rowHeight) // Raw mode: render all rows, content-visibility handles off-screen skipping. const visibleItems: VisibleItem[] = useMemo(() => { if (raw) { return path$SourceKeys.map((path$SourceKey, i) => ({ index: i, path$SourceKey })) } const end = Math.min(visibleRange.end + 1, path$SourceKeys.length) return path$SourceKeys .slice(visibleRange.start, end) .map((path$SourceKey, i) => ({ index: visibleRange.start + i, path$SourceKey })) }, [raw, visibleRange.start, visibleRange.end, path$SourceKeys]) useEffect(() => { if (isPaused) return if (visibleItems.length === 0) return const visiblePath$SourceKeys = visibleItems.map( (item) => item.path$SourceKey ) granularSubscriptionManager.requestPaths( visiblePath$SourceKeys, path$SourceKeys ) }, [ visibleRange.start, visibleRange.end, path$SourceKeys, isPaused, visibleItems ]) if (path$SourceKeys.length === 0) { return (
    No data available. Waiting for data...
    ) } return (
    Path
    {showContext && (
    Context
    )}
    Value
    Timestamp
    {spacerBeforeHeight > 0 && (
    )} {visibleItems.map((item) => ( ))} {spacerAfterHeight > 0 &&
    }
    {raw ? ( <>Showing {path$SourceKeys.length} paths ) : ( <> Showing {visibleItems.length} of {path$SourceKeys.length} paths (rows {visibleRange.start + 1}- {Math.min(visibleRange.end + 1, path$SourceKeys.length)}) )}
    ) } export default VirtualizedDataTable ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/VirtualizedMetaTable.tsx ================================================ import { memo } from 'react' import Meta from './Meta' import { useMetaData } from './usePathData' import './VirtualTable.css' interface MetaRowProps { path: string ctx: string index: number showContext: boolean } const MetaRow = memo(function MetaRow({ path, ctx, index, showContext }: MetaRowProps) { const meta = useMetaData(ctx, path) if (path.startsWith('notifications')) return null return (
    ) }) interface VirtualizedMetaTableProps { paths: string[] context: string showContext?: boolean } // Renders all rows; content-visibility: auto (in CSS) handles off-screen skipping. // Variable row heights make spacer-based virtualization impractical here. function VirtualizedMetaTable({ paths, context, showContext = false }: VirtualizedMetaTableProps) { if (paths.length === 0) { return (
    No metadata available
    ) } return (
    Path Metadata
    {paths.map((item, i) => { const separatorIndex = item.indexOf('\0') const ctx = separatorIndex !== -1 ? item.slice(0, separatorIndex) : context const path = separatorIndex !== -1 ? item.slice(separatorIndex + 1) : item return ( ) })}
    Showing {paths.length} paths
    ) } export default VirtualizedMetaTable ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/pathUtils.ts ================================================ // The same Signal K path can have multiple values from different sources. // We combine them as "path$source" to create a unique key. export function getPath$SourceKey(path: string, source?: string): string { return `${path}$${source ?? ''}` } export function getPathFromKey(path$SourceKey: string): string { const idx = path$SourceKey.indexOf('$') return idx >= 0 ? path$SourceKey.substring(0, idx) : path$SourceKey } ================================================ FILE: packages/server-admin-ui/src/views/DataBrowser/usePathData.ts ================================================ import { useRef, useSyncExternalStore, useCallback } from 'react' import { useStore } from '../../store' import type { PathData, MetaData } from '../../store' const THROTTLE_MS = 200 // max 5 UI re-renders per second per path export function usePathData( context: string, path$SourceKey: string ): PathData | null { const lastUpdateRef = useRef(0) const cachedDataRef = useRef(null) const timeoutRef = useRef | null>(null) const listenerRef = useRef<(() => void) | null>(null) const subscribe = useCallback( (onStoreChange: () => void) => { listenerRef.current = onStoreChange const unsubscribe = useStore.subscribe( (state) => state.signalkData[context]?.[path$SourceKey], (newData) => { const now = Date.now() const elapsed = now - lastUpdateRef.current if (elapsed >= THROTTLE_MS) { lastUpdateRef.current = now cachedDataRef.current = newData ?? null onStoreChange() } else { if (!timeoutRef.current) { timeoutRef.current = setTimeout(() => { lastUpdateRef.current = Date.now() cachedDataRef.current = useStore.getState().signalkData[context]?.[path$SourceKey] ?? null timeoutRef.current = null if (listenerRef.current) { listenerRef.current() } }, THROTTLE_MS - elapsed) } } }, { fireImmediately: true } ) return () => { unsubscribe() listenerRef.current = null if (timeoutRef.current) { clearTimeout(timeoutRef.current) timeoutRef.current = null } } }, [context, path$SourceKey] ) const getSnapshot = useCallback(() => { if (cachedDataRef.current === null) { cachedDataRef.current = useStore.getState().signalkData[context]?.[path$SourceKey] ?? null } return cachedDataRef.current }, [context, path$SourceKey]) return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } export function useMetaData( context: string, path: string | undefined ): MetaData | null { const metaData = useStore((s) => path ? s.signalkMeta[context]?.[path] : undefined ) return metaData ?? null } export type { PathData, MetaData } ================================================ FILE: packages/server-admin-ui/src/views/Playground.tsx ================================================ import React, { useState, useEffect, useRef, useCallback } from 'react' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Nav from 'react-bootstrap/Nav' import Row from 'react-bootstrap/Row' import Tab from 'react-bootstrap/Tab' import Table from 'react-bootstrap/Table' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCircleDot } from '@fortawesome/free-regular-svg-icons/faCircleDot' import { faSpinner } from '@fortawesome/free-solid-svg-icons/faSpinner' import dayjs from 'dayjs' import jsonlint from 'jsonlint-mod' const timestampFormat = 'MM/DD HH:mm:ss' const inputStorageKey = 'admin.v1.playground.input' const DELTAS_TAB_ID = 'deltas' const PATHS_TAB_ID = 'paths' const N2KJSON_TAB_ID = 'n2kjson' const PUTRESULTS_TAB_ID = 'putresults' const LINT_ERROR_TAB_ID = 'lintErrors' interface PathData { path: string value: unknown context: string timestamp: string } interface Delta { context?: string updates?: Array<{ timestamp: string values?: Array<{ path: string value: unknown }> }> } interface SendResponse { error?: string deltas: Delta[] n2kJson: unknown[] n2kOutAvailable: boolean putResults: unknown[] } function isJson(input: string): boolean { try { JSON.parse(input) return true } catch { return false } } function N2kJsonPanel({ n2kData }: { n2kData: unknown[] }) { return (
    {JSON.stringify(n2kData, null, 2)}
    ) } const Playground: React.FC = () => { const [data, setData] = useState([]) const [deltas, setDeltas] = useState([]) const [n2kJson, setN2kJson] = useState([]) const [n2kOutAvailable, setN2kOutAvailable] = useState(false) const [input, setInput] = useState( () => localStorage.getItem(inputStorageKey) || '' ) const [inputIsJson, setInputIsJson] = useState(() => isJson(localStorage.getItem(inputStorageKey) || '') ) const [sending, setSending] = useState(false) const [sendingN2K, setSendingN2K] = useState(false) const [activeTab, setActiveTab] = useState(DELTAS_TAB_ID) const [error, setError] = useState(null) const [jsonError, setJsonError] = useState(null) const [putResults, setPutResults] = useState([]) const inputWaitTimeoutRef = useRef(null) // Track if initial auto-send has been scheduled const initialSendScheduledRef = useRef(false) const inputRef = useRef(input) useEffect(() => { inputRef.current = input }, [input]) const send = useCallback( (sendToServer: boolean, sendToN2K = false) => { const currentInput = inputRef.current const start = currentInput.trim().charAt(0) if (start === '{' || start === '[') { try { jsonlint.parse(currentInput) if (activeTab === LINT_ERROR_TAB_ID) { setActiveTab(DELTAS_TAB_ID) } } catch (err) { setData([]) setDeltas([]) setPutResults([]) setN2kJson([]) setN2kOutAvailable(false) setError('invalid json') setJsonError((err as Error).message) setActiveTab(LINT_ERROR_TAB_ID) return } } const body = { value: currentInput, sendToServer, sendToN2K } localStorage.setItem(inputStorageKey, currentInput) if (sendToServer) { setSending(true) } if (sendToN2K) { setSendingN2K(true) } fetch(`${window.serverRoutesPrefix}/inputTest`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then((response) => response.json()) .then((responseData: SendResponse) => { if (sendToServer || sendToN2K) { setTimeout(() => { setSending(false) setSendingN2K(false) }, 1000) } if (responseData.error) { setData([]) setDeltas([]) setPutResults([]) setN2kJson([]) setN2kOutAvailable(false) setJsonError(null) setError(responseData.error) } else { setError(null) const values: PathData[] = [] responseData.deltas.forEach((delta) => { const context = delta.context || 'vessels.self' if (delta.updates) { delta.updates.forEach((update) => { if (update.values) { update.values.forEach((vp) => { if (vp.path === '') { Object.keys(vp.value as object).forEach((k) => { values.push({ path: k, value: (vp.value as Record)[k], context, timestamp: dayjs(update.timestamp).format( timestampFormat ) }) }) } else { values.push({ path: vp.path, value: vp.value, context, timestamp: dayjs(update.timestamp).format( timestampFormat ) }) } }) } }) } }) setData(values) setDeltas(responseData.deltas) setN2kJson(responseData.n2kJson) setN2kOutAvailable(responseData.n2kOutAvailable) setPutResults(responseData.putResults) setJsonError(null) } }) .catch((err) => { console.error(err) setData([]) setDeltas([]) setPutResults([]) setN2kJson([]) setN2kOutAvailable(false) setError((err as Error).message) setJsonError(null) if (sendToServer || sendToN2K) { setSending(false) setSendingN2K(false) } }) }, [activeTab] ) const handleInput = useCallback( (event: React.ChangeEvent) => { const value = event.target.value setInput(value) setInputIsJson(isJson(value)) localStorage.setItem(inputStorageKey, value) if (inputWaitTimeoutRef.current) { clearTimeout(inputWaitTimeoutRef.current) } inputWaitTimeoutRef.current = setTimeout(() => { if (value.length > 0) { send(false) } }, 500) }, [send] ) const handleExecute = useCallback(() => { send(true) }, [send]) const handleSendN2K = useCallback(() => { send(false, true) }, [send]) const beautify = useCallback(() => { const currentInput = inputRef.current try { jsonlint.parse(currentInput) const text = JSON.stringify(JSON.parse(currentInput), null, 2) setInput(text) setJsonError(null) } catch (err) { setData([]) setDeltas([]) setPutResults([]) setN2kJson([]) setN2kOutAvailable(false) setError('invalid json') setJsonError((err as Error).message) setActiveTab(LINT_ERROR_TAB_ID) } }, []) // Auto-send on mount if there's saved input // The ref is only read when scheduling the initial send, never during render useEffect(() => { if (input && input.length > 0 && !initialSendScheduledRef.current) { initialSendScheduledRef.current = true // Use setTimeout to schedule send as a callback, not synchronously const timeoutId = setTimeout(() => send(false), 0) return () => clearTimeout(timeoutId) } return undefined }, [input, send]) return (
    Input
    { e.preventDefault() }} > You can enter multi-line raw NMEA 2000, NMEA 0183 or Signal K deltas (one delta or an array). For sending PGNs out over the servers NMEA 2000 connection, use one of the formats{' '} here
    {error &&

    {error}

    }
    Output k && setActiveTab(k)} > {deltas.length > 0 && (
    {JSON.stringify(deltas, null, 2)}
    )}
    {data.length > 0 && (
    {data.map((item) => { const formatted = JSON.stringify( item.value, null, typeof item.value === 'object' && item.value !== null && Object.keys(item.value).length > 1 ? 2 : 0 ) const key = `${item.path}${item.context}` return ( ) })}
    Path Value Context
    {item.path}
                                          {formatted}
                                        
    {item.context}
    )} {n2kJson && n2kJson.length > 0 && ( )} {putResults && putResults.length > 0 && (
    {JSON.stringify(putResults, null, 2)}
    )} {jsonError && (
    {jsonError}
    )}
    ) } export default Playground ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/BackupRestore.tsx ================================================ import React, { useState, useCallback } from 'react' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Row from 'react-bootstrap/Row' import ProgressBar from 'react-bootstrap/ProgressBar' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faCircleNotch } from '@fortawesome/free-solid-svg-icons/faCircleNotch' import { faCircleDot } from '@fortawesome/free-regular-svg-icons/faCircleDot' import { useStore, useRestarting } from '../../store' import { restartAction } from '../../actions' const RESTORE_NONE = 0 const RESTORE_VALIDATING = 1 const RESTORE_CONFIRM = 2 const RESTORE_RUNNING = 3 interface RestoreStatus { state?: string message?: string percentComplete?: number } const BackupRestore: React.FC = () => { const restoreStatus = useStore( (state) => state.restoreStatus ) as RestoreStatus const restarting = useRestarting() const [restoreFile, setRestoreFile] = useState(null) const [restoreState, setRestoreState] = useState(RESTORE_NONE) const [includePlugins, setIncludePlugins] = useState(false) const [restoreContents, setRestoreContents] = useState< Record >({}) const cancelRestore = useCallback(() => { setRestoreState(RESTORE_NONE) }, []) const fileChanged = useCallback( (event: React.ChangeEvent) => { setRestoreFile(event.target.files?.[0] || null) }, [] ) const backup = useCallback(() => { const url = `${window.serverRoutesPrefix}/backup?includePlugins=${includePlugins}` window.location.href = url }, [includePlugins]) const restore = useCallback(() => { fetch(`${window.serverRoutesPrefix}/restore`, { credentials: 'include', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(restoreContents) }) .then((response) => { if (!response.ok) { return response.text() } return null }) .then((res) => { if (typeof res === 'string') { alert(res) setRestoreState(RESTORE_NONE) setRestoreFile(null) } else { setRestoreState(RESTORE_RUNNING) } }) .catch((error) => { alert(error.message) }) }, [restoreContents]) const handleRestart = useCallback(() => { restartAction() setRestoreState(RESTORE_NONE) window.location.href = '/admin/#/dashboard' }, []) const validate = useCallback(() => { if (!restoreFile) { alert('Please choose a file') return } const data = new FormData() data.append('file', restoreFile) setRestoreState(RESTORE_VALIDATING) fetch(`${window.serverRoutesPrefix}/validateBackup`, { credentials: 'include', method: 'POST', headers: { Accept: 'application/json' }, body: data }) .then((response) => { if (response.ok) { return response.json() } else { return response.text() } }) .then((res) => { if (typeof res === 'string') { alert(res) setRestoreState(RESTORE_NONE) setRestoreFile(null) } else { const contents: Record = {} ;(res as string[]).forEach((filename) => { contents[filename] = true }) setRestoreState(RESTORE_CONFIRM) setRestoreContents(contents) } }) .catch((error) => { alert(error.message) }) }, [restoreFile]) const handleRestoreFileChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setRestoreContents((prev) => ({ ...prev, [event.target.name]: value as boolean })) }, [] ) const includePluginsChange = useCallback( (event: React.ChangeEvent) => { setIncludePlugins(event.target.checked) }, [] ) const fieldColWidthMd = 10 return (
    {restoreState === RESTORE_NONE && !restoreStatus.state && ( Backup Settings
    This will backup your server and plugin settings.
    Include Plugins Selecting Yes will increase the size of the backup, but will allow for offline restore.
    {' '}
    )} Restore Settings
    {restoreState === RESTORE_NONE && !restoreStatus.state && (
    Please select the backup file from your device to use in restoring the settings. Your existing settings will be overwritten.
    )} {restoreState === RESTORE_CONFIRM && ( {Object.keys(restoreContents).map((name) => { return (
    {' '} {name}
    ) })}
    )} {restoreStatus && restoreStatus.state && restoreStatus.state !== 'Complete' && (
    {restoreStatus.state} : {restoreStatus.message}
    )} {restoreStatus.state && restoreStatus.state === 'Complete' && (
    Please Restart
    )}
    {restoreState === RESTORE_NONE && !restoreStatus.state && (
    {' '}
    )} {restoreState === RESTORE_CONFIRM && (
    {' '}
    )}
    ) } export default BackupRestore ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/BasicProvider.tsx ================================================ import { useState, useEffect, useCallback, useRef, ChangeEvent, ReactNode } from 'react' import Button from 'react-bootstrap/Button' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Row from 'react-bootstrap/Row' import N2KFilters from './N2KFilters' interface ProviderOptions { type?: string device?: string baudrate?: number port?: string host?: string interface?: string uniqueNumber?: string mfgCode?: string useCanName?: boolean useCamelCompat?: boolean createDevice?: boolean sendNetworkStats?: boolean noDataReceivedTimeout?: string remoteSelf?: string selfHandling?: string subscription?: string selfsignedcert?: boolean token?: string useDiscovery?: boolean toStdout?: string | string[] ignoredSentences?: string | string[] sentenceEvent?: string validateChecksum?: boolean appendChecksum?: boolean overrideTimestamp?: boolean removeNulls?: boolean suppress0183event?: boolean dataType?: string filename?: string gpio?: string gpioInvert?: boolean filtersEnabled?: boolean filters?: Array<{ source: string; pgn: string }> [key: string]: unknown } interface ProviderValue { type: string id: string enabled: boolean logging?: boolean isNew?: boolean options: ProviderOptions [key: string]: unknown } interface DeviceListMap { byOpenPlotter?: string[] byId?: string[] byPath?: string[] serialports?: string[] [key: string]: string[] | undefined } type OnChangeHandler = ( event: | ChangeEvent | { target: { name: string; value: unknown; type?: string } }, valueType?: string ) => void type OnPropChangeHandler = ( event: | ChangeEvent | { target: { name: string; value: unknown; type?: string } } ) => void interface BasicProviderProps { value: ProviderValue onChange: OnChangeHandler onPropChange: OnPropChangeHandler } interface TextInputProps { name: string title: string value: string | number | undefined helpText?: string onChange: OnChangeHandler } interface TextAreaInputProps { name: string title: string value: string | undefined rows?: number helpText?: string onChange: OnChangeHandler } interface DeviceInputProps { value: ProviderOptions onChange: OnChangeHandler } interface LoggingInputProps { value: ProviderValue onChange: OnChangeHandler } interface ValidateChecksumInputProps { value: ProviderOptions onChange: OnChangeHandler } interface OverrideTimestampsProps { value: ProviderOptions onChange: OnChangeHandler } interface TypeComponentProps { value: ProviderValue onChange: OnChangeHandler hasAnalyzer?: boolean } // Defined outside component to avoid recreation on render const TYPE_COMPONENTS: Record< string, React.ComponentType > = { NMEA2000: NMEA2000, NMEA0183: NMEA0183, SignalK: SignalK, Seatalk: Seatalk, FileStream: FileStream } export default function BasicProvider({ value, onChange, onPropChange }: BasicProviderProps) { const [hasAnalyzer, setHasAnalyzer] = useState(false) useEffect(() => { fetch(`${window.serverRoutesPrefix}/hasAnalyzer`, { credentials: 'include' }) .then((response) => response.json()) .then((data) => { setHasAnalyzer(data) }) }, []) const TypeComponent = TYPE_COMPONENTS[value.type] return (
    Data Type {value.isNew ? ( onChange(event)} > ) : ( value.type )} Enabled onChange(event)} checked={value.enabled} /> {value.type !== 'FileStream' && ( )} ID { const dummyEvent = { target: { name: event.target.name, type: event.target.type, value: (event.target.value || '').replace( /[^a-zA-Z\d-_]/g, '' ) } } onChange(dummyEvent) }} /> {TypeComponent && ( )} {value.type === 'NMEA2000' && ( )}
    ) } function TextInput({ name, title, value, helpText, onChange }: TextInputProps) { return ( {title} onChange(event)} /> {helpText && {helpText}} ) } function TextAreaInput({ name, title, value, rows, helpText, onChange }: TextAreaInputProps) { return ( {title} onChange(event)} /> {helpText && {helpText}} ) } interface TestConnectionResult { success: boolean authenticated?: boolean connected?: boolean self?: string server?: { id: string; version: string } error?: string } interface AccessRequestState { requestId?: string state: 'idle' | 'requesting' | 'pending' | 'polling' | 'completed' | 'error' error?: string } function TokenInput({ value, onChange }: { value: ProviderValue onChange: OnChangeHandler }) { const [testResult, setTestResult] = useState( null ) const [testing, setTesting] = useState(false) const [accessRequest, setAccessRequest] = useState({ state: 'idle' }) const pollTimerRef = useRef | null>(null) useEffect(() => { return () => { if (pollTimerRef.current) { clearTimeout(pollTimerRef.current) } } }, []) const remoteParams = useCallback( () => ({ host: value.options.host, port: value.options.port, useTLS: value.options.type === 'wss', selfsignedcert: value.options.selfsignedcert }), [ value.options.host, value.options.port, value.options.type, value.options.selfsignedcert ] ) const testConnection = useCallback(() => { setTesting(true) setTestResult(null) fetch(`${window.serverRoutesPrefix}/testSignalKConnection`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ...remoteParams(), token: value.options.token }) }) .then((response) => response.json()) .then((result: TestConnectionResult) => setTestResult(result)) .catch((err: Error) => setTestResult({ success: false, error: err.message }) ) .finally(() => setTesting(false)) }, [remoteParams, value.options.token]) const pollAccessRequestRef = useRef<(requestId: string) => void>(() => {}) const pollAccessRequest = useCallback( (requestId: string) => { fetch(`${window.serverRoutesPrefix}/checkAccessRequest`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ...remoteParams(), requestId }) }) .then((response) => response.json()) .then((data) => { if (data.state === 'COMPLETED') { const token = data.accessRequest?.token if (token) { onChange({ target: { name: 'options.token', value: token } }) setAccessRequest({ state: 'completed' }) } else if (data.accessRequest?.permission === 'DENIED') { setAccessRequest({ state: 'error', error: 'Access denied' }) } } else if (data.state === 'PENDING') { setAccessRequest({ state: 'pending', requestId }) pollTimerRef.current = setTimeout( () => pollAccessRequestRef.current(requestId), 5000 ) } else { setAccessRequest({ state: 'error', error: data.error || `Unexpected state: ${data.state}` }) } }) .catch((err: Error) => { setAccessRequest({ state: 'error', error: err.message }) }) }, [remoteParams, onChange] ) useEffect(() => { pollAccessRequestRef.current = pollAccessRequest }, [pollAccessRequest]) const requestAccess = useCallback(() => { // If we have a previous requestId from a cancelled request, resume polling if (accessRequest.requestId) { setAccessRequest({ state: 'pending', requestId: accessRequest.requestId }) pollTimerRef.current = setTimeout( () => pollAccessRequestRef.current(accessRequest.requestId!), 1000 ) return } setAccessRequest({ state: 'requesting' }) const clientId = `${value.id || 'signalk-server'}-${Date.now()}` fetch(`${window.serverRoutesPrefix}/requestAccess`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ...remoteParams(), clientId, description: `Signal K Server connection: ${value.id || 'unknown'}` }) }) .then((response) => response.json()) .then((data) => { if (data.state === 'PENDING' && data.requestId) { setAccessRequest({ state: 'pending', requestId: data.requestId }) pollTimerRef.current = setTimeout( () => pollAccessRequestRef.current(data.requestId), 5000 ) } else { setAccessRequest({ state: 'error', error: data.message || data.error || `Unexpected response: ${JSON.stringify(data)}` }) } }) .catch((err: Error) => { setAccessRequest({ state: 'error', error: err.message }) }) }, [remoteParams, value.id, accessRequest.requestId]) const cancelPolling = useCallback(() => { if (pollTimerRef.current) { clearTimeout(pollTimerRef.current) pollTimerRef.current = null } setAccessRequest((prev) => ({ state: 'idle', requestId: prev.requestId })) }, []) return ( <> Authentication Token onChange(event)} /> Use "Request Access" to request a token from the remote server. An admin on the remote server must approve the request.
    {accessRequest.state === 'pending' && (
    Waiting for approval on the remote server...
    )} {accessRequest.state === 'completed' && (
    Access approved. Token has been filled in automatically.
    )} {accessRequest.state === 'error' && (
    {accessRequest.error}
    )} {testResult && (
    {testResult.success && testResult.authenticated ? ( Connected and authenticated {testResult.server && ` \u2014 ${testResult.server.id} v${testResult.server.version}`} ) : testResult.success && !testResult.authenticated ? ( Connected but not authenticated — use Request Access or enter a token {testResult.server && ` \u2014 ${testResult.server.id} v${testResult.server.version}`} ) : ( {testResult.connected ? 'Connected but ' : ''} {testResult.error} )}
    )}
    ) } function DeviceInput({ value, onChange }: DeviceInputProps) { const [devices, setDevices] = useState({}) useEffect(() => { fetch(`${window.serverRoutesPrefix}/serialports`, { credentials: 'include' }) .then((response) => response.json()) .then((data) => { data.serialports = data.serialports.map( (portInfo: { path: string }) => portInfo.path ) setDevices(data) }) }, []) const isManualEntry = !isListedDevice(value.device, devices) const manualEntryValue = isManualEntry ? value.device === 'Enter manually' ? '' : value.device : '' return ( Serial port {serialportListOptions( ['byOpenPlotter', 'byId', 'byPath', 'serialports'], ['OpenPlotter managed:', 'by-id:', 'by-path:', 'Listed:'], devices )} onChange(event)} /> ) } const isListedDevice = ( device: string | undefined, deviceListMap: DeviceListMap ): boolean => { const list = Object.keys(deviceListMap).reduce((acc, key) => { return acc.concat(deviceListMap[key] || []) }, []) return list.includes(device || '') } const serialportListOptions = ( keys: string[], labels: string[], deviceListMap: DeviceListMap ): ReactNode[] => { return keys.reduce((acc, key, j) => { const devices = deviceListMap[key] if (devices && devices.length > 0) { acc.push( ) devices.forEach((device) => { acc.push() }) } return acc }, []) } function LoggingInput({ value, onChange }: LoggingInputProps) { return ( Data Logging onChange(event)} checked={value.logging} /> {value.logging && ( Creates hourly log files that can consume significant disk space )} ) } function ValidateChecksumInput({ value, onChange }: ValidateChecksumInputProps) { // Default to true if undefined - controlled with fallback const isValidateChecksumEnabled = value.validateChecksum ?? true const handleChange = (event: ChangeEvent) => { const newValidateChecksum = event.target.checked onChange(event) // When enabling validateChecksum, disable appendChecksum // (they are mutually exclusive - can't append checksum if validating it) if (newValidateChecksum && value.appendChecksum) { onChange({ target: { name: 'options.appendChecksum', value: false, type: 'checkbox' } }) } } return ( Validate Checksum ) } function OverrideTimestamps({ value, onChange }: OverrideTimestampsProps) { return ( Override timestamps ) } function RemoveNullsInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( Remove NULL characters onChange(event)} checked={value.removeNulls} /> ) } function AppendChecksum({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { // validateChecksum defaults to true when undefined const isValidateChecksumEnabled = value.validateChecksum ?? true return ( Append Checksum onChange(event)} checked={value.appendChecksum && !isValidateChecksumEnabled} disabled={isValidateChecksumEnabled} /> {isValidateChecksumEnabled && ( )} ) } function SentenceEventInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( ) } function DataTypeInput({ value, onChange, hasAnalyzer }: { value: ProviderValue onChange: OnChangeHandler hasAnalyzer?: boolean }) { return ( Data Type onChange(event)} > {!value.options.dataType && ( )} {value.type === 'FileStream' && ( )} ) } function BaudRateInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( onChange(event, 'number')} /> ) } function BaudRateInputCanboat({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { // Default baud rate based on device type - controlled with fallback const defaultBaudrate = value.type === 'ikonvert-canboatjs' ? 230400 : 115200 const displayBaudrate = value.baudrate ?? defaultBaudrate return ( onChange(event, 'number')} /> ) } function StdOutInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { let displayValue = value.toStdout if (Array.isArray(displayValue)) { displayValue = displayValue.join(',') } const handleChange: OnChangeHandler = (e) => { const target = e.target as { type?: string; name: string; value: unknown } onChange({ target: { type: target.type, name: target.name, value: String(target.value).split(',') } }) } return ( ) } function IgnoredSentences({ value, onChange, helpText }: { value: ProviderOptions onChange: OnChangeHandler helpText: string }) { let displayValue = value.ignoredSentences if (Array.isArray(displayValue)) { displayValue = displayValue.join(',') } const handleChange: OnChangeHandler = (e) => { const target = e.target as { type?: string; name: string; value: unknown } onChange({ target: { type: target.type, name: target.name, value: String(target.value).split(',') } }) } return ( ) } function PortInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( ) } function HostInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( ) } function NoDataReceivedTimeoutInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( ) } function RemoteSelfInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( ) } function Suppress0183Checkbox({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( Suppress nmea0183 event onChange(event)} checked={value.suppress0183event} /> ) } function UseCanNameInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( Use Can NAME in source data onChange(event)} checked={value.useCanName} /> ) } function CreateDeviceInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( Act as N2K device (createDevice) onChange(event)} checked={value.createDevice === true} /> Claim an N2K address and participate actively on the bus. Recommended for Yacht Devices gateways — when off, the gateway may drop ISO Requests for PGN 60928 / 126996 / 126998 and device identity (model, software version, serial) stays incomplete. ) } function CamelCaseCompatInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( CamcelCase Compat (for legacy N2K plugins) onChange(event)} checked={ value.useCamelCompat !== undefined ? value.useCamelCompat : true } /> ) } function CollectNetworkStatsInput({ value, onChange }: { value: ProviderOptions onChange: OnChangeHandler }) { return ( Collect Network Statistics onChange(event)} checked={value.sendNetworkStats} /> ) } function NMEA2000({ value, onChange, hasAnalyzer }: TypeComponentProps) { return (
    NMEA 2000 Source onChange(event)} > {(value.options.type === 'ngt-1' || value.options.type === 'ngt-1-canboatjs' || value.options.type === 'ydwg02-usb-canboatjs' || value.options.type === 'ikonvert-canboatjs') && (
    )} {value.options.type === 'ydwg02-canboatjs' && (
    )} {value.options.type === 'ydwg02-udp-canboatjs' && (
    )} {value.options.type === 'navlink2-tcp-canboatjs' && (
    )} {(value.options.type === 'canbus' || value.options.type === 'canbus-canboatjs') && (
    )} {(value.options.type === 'ngt-1-canboatjs' || value.options.type === 'ikonvert-canboatjs' || value.options.type === 'navlink2-tcp-canboatjs') && ( )} {(value.options.type === 'w2k-1-n2k-ascii-canboatjs' || value.options.type === 'w2k-1-n2k-actisense-canboatjs') && (
    )} {value.options.type !== undefined && value.options.type.indexOf('canboatjs') !== -1 && ( )} {value.options.type !== undefined && /^ydwg02/.test(value.options.type) && ( )}
    ) } function NMEA0183({ value, onChange }: TypeComponentProps) { return (
    NMEA 0183 Source onChange(event)} > {value.options.type === 'serial' && ( Serial ports are bidirectional. Input from the connection is parsed as NMEA0183. Configure Output Events below to connect server's NMEA0183 data for output. )} {value.options.type === 'tcpserver' && ( Accept input from clients connected to the default TCP/10110 NMEA0183 server )} {serialParams({ value, onChange })} {(value.options.type === 'tcp' || value.options.type === 'gpsd') && (
    )}
    {value.options.type === 'udp' && ( )}
    ) } function SignalK({ value, onChange }: TypeComponentProps) { return (
    SignalK Source onChange(event)} disabled={value.options.useDiscovery} > {value.options.useDiscovery && (

    This connection is deprecated, please delete it and recreate it with the connection automatically discovered at the top of the page.

    )} {!value.options.useDiscovery && (value.options.type === 'ws' || value.options.type === 'wss' || value.options.type === 'tcp') && (
    {value.options.type === 'wss' && ( Allow self signed certificates
    )} {(value.options.type === 'ws' || value.options.type === 'wss') && (
    onChange(event, 'jsonstring')} helpText="Defaults to all. This can be an array of subscriptions." />
    )}
    )} {value.options.type === 'udp' && ( )} {serialParams({ value, onChange })} {!value.options.useDiscovery && ( 'self' handling onChange(event)} > )} {!value.options.useDiscovery && value.options.selfHandling === 'manualSelf' && ( )}
    ) } const gpios = [ 4, 5, 6, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27 ].map((gpio) => `0${gpio}`.slice(-2)) function Seatalk({ value, onChange }: TypeComponentProps) { return ( GPIO Library onChange(event)} > GPIO Pin {gpios.map((gpio) => ( ))} Invert signal ) } function FileStream({ value, onChange, hasAnalyzer }: TypeComponentProps) { return (
    ) } const serialParams = ({ value, onChange }: { value: ProviderValue onChange: OnChangeHandler }) => value.options.type === 'serial' && (
    ) ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/Logging.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import ListGroup from 'react-bootstrap/ListGroup' import Row from 'react-bootstrap/Row' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAlignJustify } from '@fortawesome/free-solid-svg-icons/faAlignJustify' const Logging: React.FC = () => { const [hasData, setHasData] = useState(false) const [authorized, setAuthorized] = useState(true) const [logfileslist, setLogfileslist] = useState([]) const fetchLogfileList = useCallback(() => { fetch(`${window.serverRoutesPrefix}/logfiles/`, { credentials: 'include' }) .then((response) => { if (!response.ok) { setAuthorized(false) return null } return response.json() }) .then((logfiles: string[] | null) => { if (logfiles) { logfiles.sort() setLogfileslist(logfiles) setHasData(true) setAuthorized(true) } }) }, []) useEffect(() => { fetchLogfileList() }, [fetchLogfileList]) const logfilesToRows = useCallback((logfiles: string[]) => { // skserver-raw_2017-03-04T14.log // 012345678901234567890123456789012 const datesWithHours = logfiles.reduce>( (acc, logfile) => { const date = logfile.substr(13, 10) const hour = logfile.substr(24, 2) if (!acc[date]) { acc[date] = [] } acc[date].push(hour) return acc }, {} ) return Object.keys(datesWithHours).map((date) => { return ( {date} {datesWithHours[date].map((hour) => ( ))} ) }) }, []) if (!authorized) { return
    Not Authorized
    } if (!hasData) { return null } return (
    {' '} Data Logfiles {logfilesToRows(logfileslist)} Click button to download each logfile or
    ) } export default Logging ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/N2KFilters.tsx ================================================ import { ChangeEvent } from 'react' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Form from 'react-bootstrap/Form' import Table from 'react-bootstrap/Table' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash' import { faCirclePlus } from '@fortawesome/free-solid-svg-icons/faCirclePlus' interface N2KFilter { source: string pgn: string } interface ProviderOptions { filtersEnabled?: boolean filters?: N2KFilter[] useCanName?: boolean [key: string]: unknown } interface ProviderValue { options: ProviderOptions [key: string]: unknown } interface N2KFiltersProps { value: ProviderValue onChange: ( event: | ChangeEvent | { target: { name: string; value: unknown; type?: string } } ) => void } export default function N2KFilters({ value, onChange }: N2KFiltersProps) { const filters = value.options.filters ?? [] const handleFilterFieldChange = ( index: number, field: keyof N2KFilter, newValue: string ) => { const updatedFilters = filters.map((filter, i) => i === index ? { ...filter, [field]: newValue } : filter ) onChange({ target: { name: 'options.filters', value: updatedFilters } }) } const deleteFilter = (index: number) => { const updatedFilters = filters.filter((_, i) => i !== index) onChange({ target: { name: 'options.filters', value: updatedFilters } }) } const handleEnabledChange = (event: ChangeEvent) => { onChange({ target: { name: 'options.filtersEnabled', value: event.target.checked } }) } const handleAddFilter = () => { const updatedFilters = [...filters, { source: '', pgn: '' }] onChange({ target: { name: 'options.filters', value: updatedFilters } }) } const sourceName = value.options.useCanName ? 'Can NAME' : 'Address' return (
    Filters   Enabled

    Filter out all messages from a specific {sourceName} by entering just the {sourceName}.
    Filter out a specific PGN from all devices by entering just the PGN.
    Filter out a specific PGN from a specific {sourceName} by entering both.

    {filters.length > 0 && ( {filters.map((filter, index) => { return ( ) })}
    {sourceName} PGN
    handleFilterFieldChange( index, 'source', e.target.value ) } /> handleFilterFieldChange( index, 'pgn', e.target.value ) } />
    )}
    ) } ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/PluginConfigurationForm.tsx ================================================ import Form from '@rjsf/core' import validator from '@rjsf/validator-ajv8' import { getTemplate, getUiOptions, RJSFSchema, UiSchema, RegistryFieldsType, RegistryWidgetsType } from '@rjsf/utils' import { ReactNode } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus' import { faArrowUp } from '@fortawesome/free-solid-svg-icons/faArrowUp' import { faArrowDown } from '@fortawesome/free-solid-svg-icons/faArrowDown' import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes' import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons/faFloppyDisk' const GRID_COLUMNS = { CONTENT: 'col-9', TOOLBAR: 'col-3', ADD_BUTTON_CONTAINER: 'col-3 offset-9' } const CSS_CLASSES = { FORM_CONTROL: 'form-control', FORM_CHECK: 'form-check', FORM_CHECK_INPUT: 'form-check-input', FORM_CHECK_LABEL: 'form-check-label', BTN_INFO: 'btn btn-info', BTN_OUTLINE_DARK: 'btn btn-outline-dark', BTN_DANGER: 'btn btn-danger', ARRAY_ITEM: 'row array-item', ARRAY_ITEM_TOOLBOX: 'array-item-toolbox', ARRAY_ITEM_LIST: 'array-item-list', ARRAY_ITEM_ADD: 'row', FIELD_DESCRIPTION: 'field-description', CHECKBOX: 'checkbox ' } const isArrayItemId = (id: string | undefined): boolean => { if (!id || typeof id !== 'string') return false const parts = id.split('_') return parts.length > 2 && /^\d+$/.test(parts[parts.length - 1]) } interface ButtonProps { className?: string onClick?: (e: React.MouseEvent) => void disabled?: boolean style?: React.CSSProperties tabIndex?: number } const createButton = ( className: string, onClick: ((e: React.MouseEvent) => void) | undefined, disabled: boolean | undefined, style: React.CSSProperties | undefined, icon: ReactNode, tabIndex = 0 ) => ( ) interface ArrayFieldItemTemplateProps { children: ReactNode disabled?: boolean hasToolbar?: boolean hasMoveUp?: boolean hasMoveDown?: boolean hasRemove?: boolean index: number onDropIndexClick: (index: number) => (e?: React.MouseEvent) => void onReorderClick: ( index: number, newIndex: number ) => (e?: React.MouseEvent) => void readonly?: boolean registry: { templates: { ButtonTemplates: { MoveUpButton: React.ComponentType< ButtonProps & { uiSchema?: UiSchema; registry: unknown } > MoveDownButton: React.ComponentType< ButtonProps & { uiSchema?: UiSchema; registry: unknown } > RemoveButton: React.ComponentType< ButtonProps & { uiSchema?: UiSchema; registry: unknown } > } } fields: RegistryFieldsType widgets: RegistryWidgetsType } uiSchema?: UiSchema } const ArrayFieldItemTemplate = (props: ArrayFieldItemTemplateProps) => { const { children, disabled, hasToolbar, hasMoveUp, hasMoveDown, hasRemove, index, onDropIndexClick, onReorderClick, readonly, registry, uiSchema } = props const { MoveUpButton, MoveDownButton, RemoveButton } = registry.templates.ButtonTemplates return (
    {children}
    {hasToolbar && (
    {(hasMoveUp || hasMoveDown) && ( )} {(hasMoveUp || hasMoveDown) && ( )} {hasRemove && ( )}
    )}
    ) } interface FieldTemplateProps { id: string classNames?: string style?: React.CSSProperties label?: string help?: ReactNode required?: boolean description?: ReactNode errors?: ReactNode children: ReactNode displayLabel?: boolean schema: RJSFSchema } const FieldTemplate = (props: FieldTemplateProps) => { const { id, classNames, style, label, help, required, description, errors, children, displayLabel, schema } = props const isCheckbox = schema.type === 'boolean' const isObject = schema.type === 'object' return (
    {displayLabel && label && !isCheckbox && ( )} {description && !isObject && !isCheckbox && (

    {description}

    )} {children} {errors} {help}
    ) } interface ObjectFieldTemplateProps { title?: string description?: ReactNode properties: Array<{ content: ReactNode }> idSchema: { $id: string } } const ObjectFieldTemplate = (props: ObjectFieldTemplateProps) => { const { title, description, properties, idSchema } = props const isArrayItem = isArrayItemId(idSchema.$id) return (
    {title && !isArrayItem && ( {title} )} {description && (

    {description}

    )} {properties.map((prop) => prop.content)}
    ) } interface ArrayFieldTemplateProps { canAdd?: boolean disabled?: boolean idSchema: { $id: string } uiSchema?: UiSchema items?: Array<{ key: string; [key: string]: unknown }> onAddClick: (e?: React.MouseEvent) => void readonly?: boolean registry: { templates: { ButtonTemplates: { AddButton: React.ComponentType< ButtonProps & { uiSchema?: UiSchema; registry: unknown } > } } fields: RegistryFieldsType widgets: RegistryWidgetsType } schema: RJSFSchema title?: string } const ArrayFieldTemplate = (props: ArrayFieldTemplateProps) => { const { canAdd, disabled, idSchema, uiSchema, items, onAddClick, readonly, registry, schema, title } = props const uiOptions = getUiOptions(uiSchema) // RJSF library requires 'any' type parameters for generic template resolution. // The library's TypeScript types are designed with 'any' as default generics. const ResolvedArrayFieldItemTemplate = getTemplate< 'ArrayFieldItemTemplate', // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RJSF formData type any, RJSFSchema, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RJSF uiSchema type any >( 'ArrayFieldItemTemplate', // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RJSF registry type mismatch registry as any, uiOptions ) const { ButtonTemplates: { AddButton } } = registry.templates return (
    {(uiOptions.title || title) && ( {(uiOptions.title || title) as string} )} {(uiOptions.description || schema.description) && (
    {(uiOptions.description || schema.description) as string}
    )}
    {/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- RJSF item type */} {items?.map(({ key, ...restProps }: any) => ( ))}
    {canAdd && (

    )}
    ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RJSF template types require any const customTemplates: any = { FieldTemplate, ObjectFieldTemplate, ArrayFieldTemplate, ArrayFieldItemTemplate, ButtonTemplates: { AddButton: (props: ButtonProps) => createButton( `${CSS_CLASSES.BTN_INFO} ${props.className || ''}`, props.onClick, props.disabled, undefined, , 0 ), MoveUpButton: (props: ButtonProps) => createButton( `${CSS_CLASSES.BTN_OUTLINE_DARK} ${props.className || ''}`, props.onClick, props.disabled, undefined, , -1 ), MoveDownButton: (props: ButtonProps) => createButton( `${CSS_CLASSES.BTN_OUTLINE_DARK} ${props.className || ''}`, props.onClick, props.disabled, undefined, , -1 ), RemoveButton: (props: ButtonProps) => createButton( `${CSS_CLASSES.BTN_DANGER} ${props.className || ''}`, props.onClick, props.disabled, undefined, , -1 ), SubmitButton: (props: { uiSchema?: UiSchema }) => { const { submitText } = (props.uiSchema?.['ui:submitButtonOptions'] as { submitText?: string }) || {} return (
    ) } } } interface PluginData { enabled?: boolean enableLogging?: boolean enableDebug?: boolean configuration?: Record [key: string]: unknown } interface PluginSchema { description?: string properties?: Record } interface Plugin { data: PluginData schema: PluginSchema uiSchema?: UiSchema statusMessage?: string } interface PluginConfigurationFormProps { plugin: Plugin onSubmit: (data: PluginData) => void } export default function PluginConfigurationForm({ plugin, onSubmit }: PluginConfigurationFormProps) { const { enabled, enableLogging, enableDebug } = plugin.data // Build the schema object with proper types const formSchema: RJSFSchema = { type: 'object', ...(plugin.statusMessage && { description: `Status: ${plugin.statusMessage}` }), properties: { configuration: { type: 'object', title: ' ', description: plugin.schema.description, properties: plugin.schema.properties as RJSFSchema['properties'] } } } return (
    { onSubmit({ ...formData, enabled, enableLogging, enableDebug }) }} >
    ) } ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/ProvidersConfiguration.tsx ================================================ import React, { useState, useEffect, useRef, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Form from 'react-bootstrap/Form' import Table from 'react-bootstrap/Table' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faBan } from '@fortawesome/free-solid-svg-icons/faBan' import { faCirclePlus } from '@fortawesome/free-solid-svg-icons/faCirclePlus' import { faCircleDot } from '@fortawesome/free-regular-svg-icons/faCircleDot' import { useStore } from '../../store' import BasicProvider from './BasicProvider' import SourcePriorities from './SourcePriorities' import set from 'lodash.set' interface Provider { id: string type: string enabled: boolean logging: boolean editable: boolean options: Record json?: string isNew?: boolean wasDiscovered?: boolean originalId?: string [key: string]: unknown } const ProvidersConfiguration: React.FC = () => { const params = useParams<{ providerId?: string }>() const navigate = useNavigate() const discoveredProviders = useStore( (state) => state.discoveredProviders ) as Provider[] const [providers, setProviders] = useState([]) const [selectedProvider, setSelectedProvider] = useState( null ) const [selectedIndex, setSelectedIndex] = useState(-1) const selectedProviderRef = useRef(null) interface ProvidersData { providers: Provider[] selectedProvider: Provider | null selectedIndex: number } const loadProviders = useCallback(async (): Promise => { const response = await fetch(`${window.serverRoutesPrefix}/providers`, { credentials: 'include' }) const data: Provider[] = await response.json() let foundProvider: Provider | undefined let foundIndex: number | undefined if (params.providerId) { foundProvider = data.find((provider) => provider.id === params.providerId) foundIndex = data.findIndex( (provider) => provider.id === params.providerId ) } if (foundProvider) { foundProvider.originalId = foundProvider.id } return { providers: data, selectedProvider: foundProvider ? structuredClone(foundProvider) : null, selectedIndex: foundIndex ?? -1 } }, [params.providerId]) const runDiscovery = useCallback(() => { fetch(`${window.serverRoutesPrefix}/runDiscovery`, { method: 'PUT', credentials: 'include' }) }, []) useEffect(() => { loadProviders().then((data) => { setProviders(data.providers) setSelectedProvider(data.selectedProvider) setSelectedIndex(data.selectedIndex) }) runDiscovery() }, [loadProviders, runDiscovery]) const handleProviderChange = useCallback( ( event: | React.ChangeEvent | { target: { name: string; value: unknown; type?: string } }, valueType?: string ) => { if (!selectedProvider) return let value: unknown = event.target.type === 'checkbox' ? (event.target as HTMLInputElement).checked : event.target.value if (valueType === 'number') { value = Number(value) } const updatedProvider = { ...selectedProvider } set(updatedProvider, event.target.name, value) // createDevice only applies to YDWG source types. Drop a stale // value when the user picks a non-YDWG type so we don't submit a // hidden option that no longer has a UI control. On a brand-new // connection picking a YDWG type, default createDevice to on — // without it the gateway silently drops ISO Requests for PGN // 60928 / 126996 / 126998, leaving device identity incomplete. // Existing connections that already store a value are left alone: // an MFD on the bus may already be locked onto current $source // refs, and flipping createDevice on retroactively makes the // server claim a new address and disrupts that binding. if (event.target.name === 'options.type' && typeof value === 'string') { const isYdwg = /^ydwg02/.test(value) if (!isYdwg && updatedProvider.options?.createDevice !== undefined) { delete updatedProvider.options.createDevice } else if ( isYdwg && updatedProvider.isNew && updatedProvider.options?.createDevice === undefined ) { set(updatedProvider, 'options.createDevice', true) } } setSelectedProvider(updatedProvider) }, [selectedProvider] ) const handleAddProvider = useCallback(() => { const newProvider: Provider = { type: 'NMEA2000', logging: false, isNew: true, id: '', enabled: true, options: {}, editable: true } setSelectedProvider(structuredClone(newProvider)) setSelectedIndex(providers.length - 1) setTimeout(() => { selectedProviderRef.current?.scrollIntoView() }, 0) }, [providers.length]) const handleApply = useCallback(async () => { if (!selectedProvider) return const isNew = selectedProvider.isNew const wasDiscovered = selectedProvider.wasDiscovered const providerToSave = { ...selectedProvider } delete providerToSave.json const id = selectedProvider.originalId const response = await fetch( `${window.serverRoutesPrefix}/providers/${id && !isNew ? encodeURIComponent(id) : ''}`, { method: isNew ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(providerToSave), credentials: 'include' } ) if (response.ok) { const provider = structuredClone(selectedProvider) delete provider.isNew delete provider.wasDiscovered setProviders((prev) => { const newProviders = [...prev] if (isNew) { newProviders.push(provider) } else if (selectedIndex >= 0) { newProviders[selectedIndex] = provider } return newProviders }) if (wasDiscovered && discoveredProviders) { // Note: discoveredProviders state is managed by Zustand store // Updates arrive via WebSocket DISCOVERED_PROVIDER events } setSelectedProvider(null) setSelectedIndex(-1) navigate('/serverConfiguration/connections/-') } else { const text = await response.text() alert(text) } }, [selectedProvider, selectedIndex, discoveredProviders, navigate]) const handleCancel = useCallback(() => { setSelectedProvider(null) }, []) const handleDelete = useCallback(async () => { if (!selectedProvider) return const response = await fetch( `${window.serverRoutesPrefix}/providers/${encodeURIComponent(selectedProvider.id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, credentials: 'include' } ) if (response.ok) { setProviders((prev) => { const newProviders = [...prev] if (selectedIndex >= 0) { newProviders.splice(selectedIndex, 1) } return newProviders }) setSelectedProvider(null) setSelectedIndex(-1) runDiscovery() } else { const text = await response.text() alert(text) } }, [selectedProvider, selectedIndex, runDiscovery]) const providerClicked = useCallback((provider: Provider, index: number) => { setSelectedProvider({ ...structuredClone(provider), originalId: provider.id }) setSelectedIndex(index) setTimeout(() => { selectedProviderRef.current?.scrollIntoView() }, 0) }, []) return (
    {discoveredProviders && discoveredProviders.length > 0 && ( Discovered Connections {(discoveredProviders || []).map((provider, index) => { return ( providerClicked(provider, index)} key={provider.id} > ) })}
    ID Data Type Enabled Data Logging
    {provider.id}
    )} Connections {(providers || []).map((provider, index) => { return ( providerClicked(provider, index)} key={provider.id} > ) })}
    ID Data Type Enabled Data Logging
    {provider.id}
    {selectedProvider && (
    {selectedProvider.editable ? ( ) : ( )} {selectedProvider.editable ? (
    ) : (
    )}
    )}
    ) } interface ApplicableStatusProps { applicable: boolean toggle: boolean } const ApplicableStatus: React.FC = ({ applicable, toggle }) =>
    {applicable ? (toggle ? 'Yes' : 'No') : 'N/A'}
    interface ProviderTypeProps { provider: Provider } const ProviderType: React.FC = ({ provider }) => (
    {provider.type} {provider.type === 'FileStream' ? `/${(provider.options as { dataType?: string })?.dataType || ''}` : ''}
    ) export default ProvidersConfiguration ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/ServerLog.tsx ================================================ import { useState, useEffect, useRef, useCallback, ChangeEvent, FormEvent } from 'react' import parse from 'html-react-parser' import { useLogEntries, useClearLogEntries } from '../../store' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Row from 'react-bootstrap/Row' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAlignJustify } from '@fortawesome/free-solid-svg-icons/faAlignJustify' import LogFiles from './Logging' import Creatable from 'react-select/creatable' import { useWebSocket } from '../../hooks/useWebSocket' interface LogEntry { i: number d: string } interface LogState { entries: LogEntry[] debugEnabled?: string rememberDebug?: boolean } interface SelectOption { label: string value: string } export default function ServerLogs() { const log = useLogEntries() const clearLogEntries = useClearLogEntries() const { ws: webSocket, isConnected } = useWebSocket() const [pause, setPause] = useState(false) const [debugKeys, setDebugKeys] = useState([]) const didSubscribeRef = useRef(false) const webSocketRef = useRef(null) const unsubscribeRef = useRef<() => void>(() => {}) const subscribeToLogsIfNeeded = useCallback(() => { if ( !pause && webSocket && isConnected && (webSocket !== webSocketRef.current || !didSubscribeRef.current) ) { const sub = { context: 'vessels.self', subscribe: [{ path: 'log' }] } webSocket.send(JSON.stringify(sub)) webSocketRef.current = webSocket didSubscribeRef.current = true } }, [pause, webSocket, isConnected]) const unsubscribeToLogs = useCallback(() => { if (webSocket && webSocket.readyState === WebSocket.OPEN) { const sub = { context: 'vessels.self', unsubscribe: [{ path: 'log' }] } webSocket.send(JSON.stringify(sub)) didSubscribeRef.current = false } }, [webSocket]) useEffect(() => { unsubscribeRef.current = unsubscribeToLogs }) const fetchDebugKeys = useCallback(() => { fetch(`${window.serverRoutesPrefix}/debugKeys`, { credentials: 'include' }) .then((response) => response.json()) .then((keys) => { setDebugKeys(keys.sort()) }) .catch(() => {}) }, []) useEffect(() => { fetchDebugKeys() return () => { unsubscribeRef.current() clearLogEntries() } }, [fetchDebugKeys, clearLogEntries]) useEffect(() => { subscribeToLogsIfNeeded() }, [subscribeToLogsIfNeeded]) const doHandleDebug = (value: string) => { fetch(`${window.serverRoutesPrefix}/debug`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value }), credentials: 'include' }).then((response) => response.text()) } const handleRememberDebug = (event: ChangeEvent) => { fetch(`${window.serverRoutesPrefix}/rememberDebug`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.checked }), credentials: 'include' }).then((response) => response.text()) } const handlePause = (event: ChangeEvent) => { const newPause = event.target.checked setPause(newPause) if (newPause) { unsubscribeToLogs() } else { subscribeToLogsIfNeeded() } } const handleSubmit = (e: FormEvent) => { e.preventDefault() } return (
    Server Log
    ({ label: key, value: key }))} value={ log.debugEnabled ? log.debugEnabled .split(',') .map((value) => ({ label: value, value })) : null } onChange={(v) => { const value = v !== null ? (v as SelectOption[]) .map(({ value }) => value) .join(',') : '' doHandleDebug(value) }} /> Select the appropriate debug keys to activate debug logging for various components on the server. Persist debug settings over server restarts{' '} Pause the log window{' '}
    ) } interface LogListProps { value: LogState } function LogList({ value }: LogListProps) { const containerRef = useRef(null) useEffect(() => { const el = containerRef.current if (el) { el.scrollTop = el.scrollHeight } }, [value.entries]) return (
    {value.entries.length === 0 ? ( Waiting for log entries... ) : ( value.entries.map((logEntry) => ( )) )}
    ) } interface LogRowProps { log: string } function LogRow({ log }: LogRowProps) { return ( {parse(log)}
    ) } ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/ServerUpdate.tsx ================================================ import React, { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import { useAppStore } from '../../store' interface InstallingApp { name: string isWaiting?: boolean isInstalling?: boolean } interface AppStore { storeAvailable: boolean canUpdateServer: boolean isInDocker: boolean serverUpdate: string | null installing: InstallingApp[] } const ServerUpdate: React.FC = () => { const navigate = useNavigate() const appStore = useAppStore() as AppStore const handleUpdate = useCallback(() => { if (confirm('Are you sure you want to update the server?')) { navigate('/appstore/updates') fetch( `${window.serverRoutesPrefix}/appstore/install/signalk-server/${appStore.serverUpdate}`, { method: 'POST', credentials: 'include' } ) } }, [appStore.serverUpdate, navigate]) if (!appStore.storeAvailable) { return (
    Waiting for App store data to load...
    ) } let isInstalling = false let isInstalled = false const info = appStore.installing.find((p) => p.name === 'signalk-server') if (info) { if (info.isWaiting || info.isInstalling) { isInstalling = true } else { isInstalled = true } } return (
    {!appStore.canUpdateServer && ( Server Update This installation is not updatable from the admin user interface. )} {appStore.isInDocker && ( Running as a Docker container

    The server is running as a Docker container. You need to pull a new server version from Container registry to update.

                  docker pull cr.signalk.io/signalk/signalk-server
                

    More info about running Signal K in Docker can be found at{' '} Docker README {' '} .

    )} {appStore.canUpdateServer && appStore.serverUpdate && !isInstalling && !isInstalled && ( Server version {appStore.serverUpdate} is available Release Notes for latest releases.

    )} {isInstalling && ( Server Update The update is being installed )} {isInstalled && ( Server Update The update has been installed, please restart the Signal K server. )} {appStore.canUpdateServer && !appStore.serverUpdate && ( Server Update Your server is up to date. )} Sponsoring

    If you find Signal K valuable to you consider sponsoring our work on developing it further.

    Your support allows us to do things like

    • travel to meet in person and push things forward
    • purchase equipment to develop on
    • upgrade our cloud resources beyond the free tiers

    See{' '} Signal K in Open Collective {' '} for details.

    ) } export default ServerUpdate ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/Settings.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react' import Badge from 'react-bootstrap/Badge' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Row from 'react-bootstrap/Row' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAlignJustify } from '@fortawesome/free-solid-svg-icons/faAlignJustify' import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons/faFloppyDisk' import VesselConfiguration from './VesselConfiguration' import UnitPreferencesSettings from './UnitPreferencesSettings' import Logging from './Logging' interface ServerSettingsData { hasData?: boolean port?: string sslport?: string runFromSystemd?: boolean options?: Record interfaces?: Record pruneContextsMinutes?: string loggingDirectory?: string keepMostRecentLogsOnly?: boolean logCountToKeep?: string courseApi?: { apiOnly?: boolean } } const SettableInterfaces: Record = { applicationData: 'Application Data Storage', logfiles: 'Data log files access', 'nmea-tcp': 'NMEA 0183 over TCP (10110)', tcp: 'Signal K over TCP (8375)', wasm: 'WebAssembly Runtime' } const ServerSettings: React.FC = () => { const [settings, setSettings] = useState({ hasData: false }) const fetchSettings = useCallback(() => { fetch(`${window.serverRoutesPrefix}/settings`, { credentials: 'include' }) .then((response) => response.json()) .then((data: ServerSettingsData) => { setSettings({ ...data, hasData: true }) }) }, []) useEffect(() => { fetchSettings() }, [fetchSettings]) const handleChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setSettings((prev) => ({ ...prev, [event.target.name]: value })) }, [] ) const handleCourseApiChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setSettings((prev) => ({ ...prev, courseApi: { ...prev.courseApi, [event.target.name]: value } })) }, [] ) const handleOptionChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setSettings((prev) => ({ ...prev, options: { ...prev.options, [event.target.name]: value as boolean } })) }, [] ) const handleInterfaceChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setSettings((prev) => ({ ...prev, interfaces: { ...prev.interfaces, [event.target.name]: value as boolean } })) }, [] ) const handleSaveSettings = useCallback(() => { fetch(`${window.serverRoutesPrefix}/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings), credentials: 'include' }) .then((response) => response.text()) .then((response) => { alert(response) }) }, [settings]) const fieldColWidthMd = 10 if (!settings.hasData) { return null } return (
    {' '} Server Settings
    {!settings.runFromSystemd && ( HTTP Port Saving a new value here will not have effect if overridden by environment variable PORT )} {settings.runFromSystemd && ( The server was started by systemd, run signalk-server-setup to change ports and ssl configuration. )} {settings.options?.ssl && !settings.runFromSystemd && ( SSL Port Saving a new value here will not have effect if overridden by environment variable SSLPORT )} Options {settings.options && Object.keys(settings.options).map((name) => { return (
    {name}
    ) })}
    Interfaces {Object.keys(SettableInterfaces).map((name) => { return (
    {SettableInterfaces[name]}
    ) })}
    Maximum age of inactive vessels' data Vessels that have not been updated after this many minutes will be removed Data Logging Directory Connections and plugins that have logging enabled create hourly log files in Multiplexed format in this directory. This can consume significant disk space — enable the option below to limit retention. Keep only most recent data log files
    Number of log files to keep How many hourly files to keep
    API Only Mode
    (Course API)
    Accept course operations only via HTTP requests. Destination data from NMEA sources is not used.
    {' '} Restart Required
    ) } const Settings: React.FC = () => { return (
    ) } export default Settings ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/SourcePriorities.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react' import Alert from 'react-bootstrap/Alert' import Badge from 'react-bootstrap/Badge' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Collapse from 'react-bootstrap/Collapse' import Form from 'react-bootstrap/Form' import Table from 'react-bootstrap/Table' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faArrowUp } from '@fortawesome/free-solid-svg-icons/faArrowUp' import { faArrowDown } from '@fortawesome/free-solid-svg-icons/faArrowDown' import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash' import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons/faFloppyDisk' import Creatable from 'react-select/creatable' import uniq from 'lodash.uniq' import { useStore, useSourcePriorities } from '../../store' // Types interface Priority { sourceRef: string timeout: string | number } interface PathPriority { path: string priorities: Priority[] } interface SelectOption { label: string value: string } function fetchSourceRefs(path: string, cb: (refs: string[]) => void) { fetch(`/signalk/v1/api/vessels/self/${path.replace(/\./g, '/')}`, { credentials: 'include' }) .then((response) => response.json()) .then((pathResponse) => { let sourceRefs = [pathResponse.$source] if (pathResponse.values) { sourceRefs = sourceRefs.concat(Object.keys(pathResponse.values)) } return uniq(sourceRefs) }) .then(cb) } interface PrefsEditorProps { path: string priorities: Priority[] pathIndex: number isSaving: boolean } const PrefsEditor: React.FC = ({ path, priorities, pathIndex, isSaving }) => { const changePriority = useStore((s) => s.changePriority) const deletePriority = useStore((s) => s.deletePriority) const movePriority = useStore((s) => s.movePriority) const [isOpen, setIsOpen] = useState(false) const [sourceRefs, setSourceRefs] = useState([]) useEffect(() => { if (path) { fetchSourceRefs(path, (refs) => { setSourceRefs(refs) }) } }, [path]) const toggleEditor = () => setIsOpen((prev) => !prev) const options: SelectOption[] = sourceRefs.map((ref) => ({ label: ref, value: ref })) return (
    {!isOpen &&
    ...
    } {[...priorities, { sourceRef: '', timeout: '' }].map( ({ sourceRef, timeout }, index) => { // Priority items are ordered and may have empty sourceRef const priorityKey = `${index}-${sourceRef || 'new'}` return ( ) } )}
    # Source Reference (see DataBrowser for details) Timeout (ms) Order
    {index + 1}. { changePriority( pathIndex, index, e?.value || '', timeout ) }} /> {index > 0 && ( changePriority( pathIndex, index, sourceRef, e.target.value ) } value={timeout} /> )} {index > 0 && index < priorities.length && ( )} {index < priorities.length - 1 && ( )} {index < priorities.length && ( !isSaving && deletePriority(pathIndex, index) } /> )}
    ) } function fetchAvailablePaths(cb: (paths: string[]) => void) { fetch(`${window.serverRoutesPrefix}/availablePaths`, { credentials: 'include' }) .then((response) => response.json()) .then(cb) } const SourcePriorities: React.FC = () => { const sourcePrioritiesData = useSourcePriorities() const changePath = useStore((s) => s.changePath) const deletePath = useStore((s) => s.deletePath) const setSaving = useStore((s) => s.setSaving) const setSaved = useStore((s) => s.setSaved) const setSaveFailed = useStore((s) => s.setSaveFailed) const clearSaveFailed = useStore((s) => s.clearSaveFailed) const { sourcePriorities, saveState } = sourcePrioritiesData const [availablePaths, setAvailablePaths] = useState([]) useEffect(() => { fetchAvailablePaths((pathsArray) => { setAvailablePaths( pathsArray.map((path) => ({ value: path, label: path })) ) }) }, []) const handleSave = useCallback( (e: React.MouseEvent) => { e.preventDefault() setSaving() fetch(`${window.serverRoutesPrefix}/sourcePriorities`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( sourcePriorities.reduce>( (acc, pathPriority) => { acc[pathPriority.path] = pathPriority.priorities return acc }, {} ) ) }) .then((response) => { if (response.status === 200) { setSaved() } else { throw new Error() } }) .catch(() => { setSaveFailed() setTimeout(() => clearSaveFailed(), 5000) }) }, [sourcePriorities, setSaving, setSaved, setSaveFailed, clearSaveFailed] ) const priosWithEmpty: PathPriority[] = [ ...sourcePriorities, { path: '', priorities: [] } ] return ( Source Priorities Settings

    Use Source Priorities to filter incoming data so that data from lower priority sources is discarded when there is fresh data from some higher priority source.

    Incoming data is not handled if the{' '} latest value for a path is from a higher priority source and it is not older than the timeout {' '} specified for the source of the incoming data. Timeout for data from unlisted sources is 10 seconds.

    You can debug the settings by saving them and activating debug key{' '} signalk-server:sourcepriorities in{' '} Server Log

    {priosWithEmpty.map(({ path, priorities }, index) => { // Path items may be empty for new entries const pathKey = `${index}-${path || 'new'}` return ( ) })}
    Path Priorities
    { changePath(index, e?.value || '') }} /> {index < sourcePriorities.length && ( deletePath(index)} /> )}
    {saveState.saveFailed && 'Saving priorities settings failed!'} {!saveState.timeoutsOk && ( Error { 'The timeout values need to be numbers in ascending order, please fix.' } )}
    ) } export default SourcePriorities ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/UnitPreferencesSettings.tsx ================================================ import React, { useState, useEffect, useRef, useCallback } from 'react' import Alert from 'react-bootstrap/Alert' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Row from 'react-bootstrap/Row' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faUpload } from '@fortawesome/free-solid-svg-icons/faUpload' import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash' import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes' import { faSliders } from '@fortawesome/free-solid-svg-icons/faSliders' import { useLoginStatus, useActivePreset, useServerDefaultPreset, usePresets, useStore } from '../../store' type UploadStatus = 'uploading' | 'success' | 'duplicate' | 'error' | null const UnitPreferencesSettings: React.FC = () => { const loginStatus = useLoginStatus() const activePreset = useActivePreset() const serverDefaultPreset = useServerDefaultPreset() const presets = usePresets() const unitPrefsLoaded = useStore((s) => s.unitPrefsLoaded) const fetchUnitPreferences = useStore((s) => s.fetchUnitPreferences) const setActivePresetAndSave = useStore((s) => s.setActivePresetAndSave) const setServerDefaultPreset = useStore((s) => s.setServerDefaultPreset) const setPresets = useStore((s) => s.setPresets) const [uploadStatus, setUploadStatus] = useState(null) const [uploadError, setUploadError] = useState(null) const [duplicatePresetName, setDuplicatePresetName] = useState( null ) const [pendingFile, setPendingFile] = useState(null) const [updateServerDefault, setUpdateServerDefault] = useState(false) const fileInputRef = useRef(null) useEffect(() => { if (loginStatus.status === 'loggedIn') { fetchUnitPreferences() } }, [fetchUnitPreferences, loginStatus.status, loginStatus.username]) const isAdmin = !loginStatus.authenticationRequired || (loginStatus.status === 'loggedIn' && (loginStatus as Record).userLevel === 'admin') const handlePresetChange = useCallback( async (preset: string) => { await setActivePresetAndSave(preset) if (updateServerDefault) { await setServerDefaultPreset(preset) } }, [setActivePresetAndSave, setServerDefaultPreset, updateServerDefault] ) const refreshPresets = useCallback(async () => { try { const response = await fetch('/signalk/v1/unitpreferences/presets', { credentials: 'include' }) if (response.ok) { const data = await response.json() const fetched = [] if (data.builtIn) { for (const p of data.builtIn) { fetched.push({ name: typeof p === 'object' ? p.name : p, label: typeof p === 'object' ? p.displayName || p.name : p, isCustom: false, isBuiltIn: true }) } } if (data.custom) { for (const p of data.custom) { fetched.push({ name: typeof p === 'object' ? p.name : p, label: typeof p === 'object' ? p.displayName || p.name : p, isCustom: true, isBuiltIn: false }) } } if (fetched.length > 0) { setPresets(fetched) } } } catch (e) { console.error('Failed to refresh presets:', e) } }, [setPresets]) const handleFileUpload = useCallback( async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return setUploadStatus('uploading') setUploadError(null) setDuplicatePresetName(null) setPendingFile(file) const formData = new FormData() formData.append('preset', file) try { const response = await fetch( '/signalk/v1/unitpreferences/presets/custom/upload', { method: 'POST', credentials: 'include', body: formData } ) const result = await response.json() if (response.ok) { setUploadStatus('success') setUploadError(null) setPendingFile(null) await refreshPresets() setTimeout(() => setUploadStatus(null), 3000) } else if (response.status === 409 && result.error === 'duplicate') { setUploadStatus('duplicate') setDuplicatePresetName(result.existingName) } else { setUploadStatus('error') setUploadError(result.error || 'Upload failed') setPendingFile(null) } } catch (e) { setUploadStatus('error') setUploadError(e instanceof Error ? e.message : 'Upload failed') setPendingFile(null) } if (fileInputRef.current) { fileInputRef.current.value = '' } }, [refreshPresets] ) const handleDeletePreset = useCallback( async (presetName: string) => { if (!window.confirm(`Delete custom preset "${presetName}"?`)) { return } try { const response = await fetch( `/signalk/v1/unitpreferences/presets/custom/${presetName}`, { method: 'DELETE', credentials: 'include' } ) if (response.ok) { await refreshPresets() if (activePreset === presetName) { await setActivePresetAndSave('metric') } } else { const result = await response.json() setUploadStatus('error') setUploadError(result.error || 'Delete failed') } } catch (e) { setUploadStatus('error') setUploadError(e instanceof Error ? e.message : 'Delete failed') } }, [activePreset, refreshPresets, setActivePresetAndSave] ) const handleReplacePreset = useCallback(async () => { if (!duplicatePresetName || !pendingFile) return setUploadStatus('uploading') try { const deleteResponse = await fetch( `/signalk/v1/unitpreferences/presets/custom/${duplicatePresetName}`, { method: 'DELETE', credentials: 'include' } ) if (!deleteResponse.ok) { const result = await deleteResponse.json() setUploadStatus('error') setUploadError(result.error || 'Failed to replace preset') setDuplicatePresetName(null) setPendingFile(null) return } const formData = new FormData() formData.append('preset', pendingFile) const uploadResponse = await fetch( '/signalk/v1/unitpreferences/presets/custom/upload', { method: 'POST', credentials: 'include', body: formData } ) const result = await uploadResponse.json() if (uploadResponse.ok) { setUploadStatus('success') setUploadError(null) setDuplicatePresetName(null) setPendingFile(null) await refreshPresets() setTimeout(() => setUploadStatus(null), 3000) } else { setUploadStatus('error') setUploadError(result.error || 'Upload failed') setDuplicatePresetName(null) setPendingFile(null) } } catch (e) { setUploadStatus('error') setUploadError(e instanceof Error ? e.message : 'Replace failed') setDuplicatePresetName(null) setPendingFile(null) } }, [duplicatePresetName, pendingFile, refreshPresets]) const dismissError = useCallback(() => { setUploadStatus(null) setUploadError(null) setDuplicatePresetName(null) setPendingFile(null) }, []) if (!unitPrefsLoaded) { return null } const builtInPresets = presets.filter((p) => !p.isCustom) const customPresets = presets.filter((p) => p.isCustom) const activeCustomPreset = customPresets.find((p) => p.name === activePreset) return ( Unit Preferences Display Units
    ) => handlePresetChange(e.target.value) } style={{ maxWidth: '300px' }} > {builtInPresets.map((p) => ( ))} {customPresets.length > 0 && ( {customPresets.map((p) => ( ))} )} {isAdmin && activeCustomPreset && ( )}
    {isAdmin && (
    ) => { const checked = e.target.checked setUpdateServerDefault(checked) if (checked) { await setServerDefaultPreset(activePreset) } }} label="Also set as server default (for new users)" /> Current server default: {serverDefaultPreset}
    )} {isAdmin && (
    Add custom preset
    )} {uploadStatus === 'success' && ( Preset uploaded successfully! )} {uploadStatus === 'duplicate' && (
    A preset named "{duplicatePresetName}" already exists.
    )} {uploadStatus === 'error' && (
    {uploadError}
    )}
    ) } export default UnitPreferencesSettings ================================================ FILE: packages/server-admin-ui/src/views/ServerConfig/VesselConfiguration.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react' import Button from 'react-bootstrap/Button' import Card from 'react-bootstrap/Card' import Col from 'react-bootstrap/Col' import Form from 'react-bootstrap/Form' import Row from 'react-bootstrap/Row' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faAlignJustify } from '@fortawesome/free-solid-svg-icons/faAlignJustify' import { faFloppyDisk } from '@fortawesome/free-solid-svg-icons/faFloppyDisk' interface VesselData { name?: string mmsi?: string callsignVhf?: string uuid?: string aisShipType?: string draft?: string length?: string beam?: string height?: string gpsFromBow?: string gpsFromCenter?: string } const VesselConfiguration: React.FC = () => { const [hasData, setHasData] = useState(false) const [vesselData, setVesselData] = useState({}) const fetchVessel = useCallback(() => { fetch(`${window.serverRoutesPrefix}/vessel`, { credentials: 'include' }) .then((response) => response.json()) .then((data: VesselData) => { setVesselData(data) setHasData(true) }) }, []) useEffect(() => { fetchVessel() }, [fetchVessel]) const handleChange = useCallback( (event: React.ChangeEvent) => { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value setVesselData((prev) => ({ ...prev, [event.target.name]: value })) }, [] ) const handleSaveVessel = useCallback(() => { fetch(`${window.serverRoutesPrefix}/vessel`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(vesselData), credentials: 'include' }) .then((response) => response.text()) .then((response) => { alert(response) }) }, [vesselData]) if (!hasData) { return null } return (
    {' '} Vessel Base Data
    Name The name of the vessel MMSI Leave blank if there is no mmsi Call Sign Leave blank if there is no call sign UUID Ignored if MMSI is set Ship Type Draft The maximum draft in meters of the vessel Length The overall length of the vessel in meters Beam The beam of the vessel in meters Height The total height of the vessel in meters{' '} GPS Distance From Bow The distance of the gps receiver from the bow in meters GPS Distance From Center The distance from the center of vessel of the gps receiver in meters
    ) } export default VesselConfiguration ================================================ FILE: packages/server-admin-ui/src/views/Webapps/Embedded.tsx ================================================ import { useEffect, useMemo, useCallback, Suspense, createElement, ComponentType, Component, ReactNode } from 'react' import { useLoginStatus } from '../../store' import { useParams } from 'react-router-dom' import { toLazyDynamicComponent, APP_PANEL } from './dynamicutilities' import Login from '../../views/security/Login' import ReconnectingWebSocket from 'reconnecting-websocket' import { LoginStatus } from '../../store/types' // Error boundary for catching fatal React errors from webapps (e.g., React 19 incompatibility) // This boundary only catches errors during React's render phase, not errors in event handlers interface WebappErrorBoundaryState { hasError: boolean error: Error | null } interface WebappErrorBoundaryProps { children: ReactNode webappName: string } class WebappErrorBoundary extends Component< WebappErrorBoundaryProps, WebappErrorBoundaryState > { override state: WebappErrorBoundaryState = { hasError: false, error: null } static getDerivedStateFromError(error: Error): WebappErrorBoundaryState { return { hasError: true, error } } handleRetry = () => { this.setState({ hasError: false, error: null }) } override render() { if (this.state.hasError) { const errorMessage = this.state.error?.message || '' // Check if this looks like a React version incompatibility error const isReactIncompatibility = errorMessage.includes('Minified React error') || errorMessage.includes('Element type is invalid') || errorMessage.includes('Cannot read properties of undefined') || errorMessage.includes('Cannot access') || errorMessage.includes('#306') || errorMessage.includes('#130') || errorMessage.includes('#152') return (
    Webapp Error

    The webapp {this.props.webappName} encountered an error. {isReactIncompatibility && ( <> {' '} This webapp may need to be updated for React 19 compatibility. )}

    Technical details
                    {this.state.error?.message}
                  
    ) } return this.props.children } } const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws' // Module-level websocket tracking to avoid ref access during render // Each Embedded component instance gets its own array keyed by moduleId const moduleWebsockets = new Map() interface WebSocketParams { subscribe?: string sendCachedValues?: boolean events?: string } interface AdminUI { hideSideBar: () => void getApplicationUserData: ( appDataVersion: string, path?: string ) => Promise setApplicationUserData: ( appDataVersion: string, data?: object, path?: string ) => Promise openWebsocket: (wsParams: WebSocketParams) => ReconnectingWebSocket get: (params: { context: string; path: string }) => Promise Login: typeof Login } interface EmbeddedComponentProps { loginStatus: LoginStatus adminUI: AdminUI } export default function Embedded() { const loginStatus = useLoginStatus() const params = useParams<{ moduleId: string }>() const moduleId = params.moduleId ?? '' const component = useMemo( () => moduleId ? (toLazyDynamicComponent( moduleId, APP_PANEL ) as ComponentType) : null, [moduleId] ) useEffect(() => { if (!moduleWebsockets.has(moduleId)) { moduleWebsockets.set(moduleId, []) } const cleanupModuleId = moduleId return () => { const websockets = moduleWebsockets.get(cleanupModuleId) if (websockets) { websockets.forEach((ws) => { try { ws.close() } catch (e) { console.error(e) } }) moduleWebsockets.delete(cleanupModuleId) } } }, [moduleId]) const openWebsocket = useCallback( (wsParams: WebSocketParams) => { const knownParams: (keyof WebSocketParams)[] = [ 'subscribe', 'sendCachedValues', 'events' ] const queryParam = knownParams .map((p, i) => [i, wsParams[p]] as [number, unknown]) .filter((x) => x[1] !== undefined) .map(([i, v]) => `${knownParams[i]}=${v}`) .join('&') const ws = new ReconnectingWebSocket( `${wsProto}://${window.location.host}/signalk/v1/stream?${queryParam}` ) const websockets = moduleWebsockets.get(moduleId) if (websockets) { websockets.push(ws) } return ws }, [moduleId] ) const adminUI: AdminUI = useMemo( () => ({ hideSideBar: () => { window.dispatchEvent(new Event('sidebar:hide')) }, getApplicationUserData: (appDataVersion: string, path = '') => fetch( `/signalk/v1/applicationData/user/${moduleId}/${appDataVersion}${path}`, { credentials: 'include' } ) .then((r) => { if (r.status !== 200) { throw new Error(String(r.status)) } return r }) .then((r) => r.json()), setApplicationUserData: (appDataVersion: string, data = {}, path = '') => fetch( `/signalk/v1/applicationData/user/${moduleId}/${appDataVersion}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), credentials: 'include' } ).then((r) => { if (r.status !== 200) { throw new Error(String(r.status)) } return r }), openWebsocket, get: ({ context, path }) => { const cParts = context.split('.') return fetch( `/signalk/v1/api/${cParts[0]}/${cParts.slice(1).join('.')}/${path}`, { credentials: 'include' } ) }, Login }), [moduleId, openWebsocket] ) if (!component) { return
    Loading...
    } return (
    {createElement(component, { loginStatus, adminUI })}
    ) } ================================================ FILE: packages/server-admin-ui/src/views/Webapps/EmbeddedAsyncApi.tsx ================================================ import { useEffect, useState } from 'react' interface AsyncApiSpec { title: string url: string } interface AsyncApiDoc { asyncapi: string info: { title: string version: string description?: string descriptionHtml?: string } servers?: Record< string, { host: string; protocol: string; pathname?: string; description?: string } > channels?: Record< string, { address?: string description?: string messages?: Record< string, { name?: string title?: string summary?: string payload?: Record } > } > operations?: Record< string, { action: string; summary?: string; description?: string } > } function schemaToString(schema: Record, indent = 0): string { if (!schema) return 'any' const pad = ' '.repeat(indent) if (schema.anyOf) return (schema.anyOf as Record[]) .map((s) => schemaToString(s, indent)) .join(' | ') if (schema.const !== undefined) return JSON.stringify(schema.const) if ( schema.type === 'object' && schema.properties && typeof schema.properties === 'object' ) { const props = schema.properties as Record> const lines = ['{'] for (const k of Object.keys(props)) { lines.push(`${pad} ${k}: ${schemaToString(props[k], indent + 1)}`) } lines.push(`${pad}}`) return lines.join('\n') } if (schema.type === 'array' && schema.items) return ( schemaToString(schema.items as Record, indent) + '[]' ) if (schema.type) return schema.type as string return 'any' } export default function EmbeddedAsyncApi() { const [specs, setSpecs] = useState([]) const [selectedIdx, setSelectedIdx] = useState(0) const [doc, setDoc] = useState(null) const [error, setError] = useState('') useEffect(() => { fetch('/skServer/asyncapi') .then((r) => { if (!r.ok) throw new Error(`${r.status} ${r.statusText}`) return r.json() }) .then((list) => { const s = list.map((item: { title: string; jsonUrl: string }) => ({ title: item.title, url: item.jsonUrl })) setSpecs(s) }) .catch((e) => setError(e.message)) }, []) useEffect(() => { if (specs.length === 0) return setError('') setDoc(null) fetch(specs[selectedIdx].url) .then((r) => { if (!r.ok) throw new Error(`${r.status} ${r.statusText}`) return r.json() }) .then(setDoc) .catch((e) => setError(e.message)) }, [specs, selectedIdx]) if (error) return

    Error: {error}

    return (
    {!doc ? (

    Loading...

    ) : ( <>

    {doc.info.title}

    v{doc.info.version} — AsyncAPI {doc.asyncapi} {doc.info.descriptionHtml && (
    )} {doc.servers && ( <>
    Servers
    {Object.entries(doc.servers).map(([name, srv]) => (
    {name}{' '} {srv.protocol}
    Host: {srv.host} {srv.pathname && <> — Path: {srv.pathname}}
    {srv.description && (
    {srv.description}
    )}
    ))} )} {doc.channels && ( <>
    Channels
    {Object.entries(doc.channels).map(([cname, ch]) => (
    {ch.address || cname} {ch.description && (
    {ch.description}
    )} {ch.messages && ( <>
    Messages
    {Object.entries(ch.messages).map(([mname, msg]) => (
    {msg.name || mname} {msg.title && ( {msg.title} )} {msg.summary && (
    {msg.summary}
    )} {msg.payload && (
                                  {schemaToString(
                                    msg.payload as Record
                                  )}
                                
    )}
    ))} )}
    ))} )} {doc.operations && ( <>
    Operations
    {Object.entries(doc.operations).map(([oname, op]) => (
    {op.action} {oname} {op.summary && (
    {op.summary}
    )}
    ))} )} )}
    ) } ================================================ FILE: packages/server-admin-ui/src/views/Webapps/EmbeddedDocs.tsx ================================================ import { useCallback, useEffect, useRef } from 'react' import { useLocation, useNavigate } from 'react-router-dom' export default function EmbeddedDocs() { const location = useLocation() const navigate = useNavigate() const iframeRef = useRef(null) const currentPathRef = useRef(location.pathname) const currentHashRef = useRef(location.hash) const docsBase = `${window.location.protocol}//${window.location.host}/documentation/` const routeSubPath = location.pathname.replace('/documentation', '') || '/' const initialSrc = docsBase + routeSubPath.replace(/^\//, '') + (location.hash || '') useEffect(() => { currentPathRef.current = location.pathname currentHashRef.current = location.hash }, [location.pathname, location.hash]) useEffect(() => { document.body.classList.add('sidebar-hidden') return () => { document.body.classList.remove('sidebar-hidden') } }, []) const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current if (!iframe?.contentWindow) return try { const iframePath = iframe.contentWindow.location.pathname const hash = iframe.contentWindow.location.hash || '' const subPath = iframePath.replace('/documentation', '') || '/' const currentRouteSubPath = currentPathRef.current.replace('/documentation', '') || '/' if (subPath !== currentRouteSubPath || hash !== currentHashRef.current) { navigate('/documentation' + subPath + hash, { replace: true }) } iframe.contentWindow.addEventListener('hashchange', () => { const newHash = iframe.contentWindow!.location.hash || '' const currentSubPath = iframe.contentWindow!.location.pathname.replace( '/documentation', '' ) || '/' navigate('/documentation' + currentSubPath + newHash, { replace: true }) }) } catch (_e) { // Cross-origin fallback (shouldn't happen with same-origin docs) } }, [navigate]) useEffect(() => { const iframe = iframeRef.current if (!iframe?.contentWindow) return try { const iframePath = iframe.contentWindow.location.pathname const iframeSubPath = iframePath.replace('/documentation', '') || '/' const iframeHash = iframe.contentWindow.location.hash || '' if ( routeSubPath !== iframeSubPath || (location.hash || '') !== iframeHash ) { iframe.contentWindow.location.href = docsBase + routeSubPath.replace(/^\//, '') + (location.hash || '') } } catch (_e) { // Cross-origin fallback } }, [docsBase, routeSubPath, location.hash]) return (