From 87d2bb8049084d1263e1ed0e39ae744c7a634763 Mon Sep 17 00:00:00 2001 From: JC Lima Date: Mon, 2 Nov 2026 20:12:22 -0800 Subject: [PATCH 01/11] Prepare VesselStack 0.6.0 Installer upgrade safety: every env value (including preserved and generated secrets) is checked before anything changes, also under --dry-run; env files are written atomically (0600 temp file, renamed into place); stale temp files from a killed run are removed. Boat Chat settings protection: Settings loads before it opens and saves only changed fields; blanking required settings is rejected with a 400; settings endpoints require the access code (GET, HEAD and POST accept X-Boat-Chat-Access-Token); the access code cannot be cleared through the API unless BOAT_CHAT_ALLOW_OPEN=true. Wizard, Control Panel and Boat Chat UX updates, with accessibility and brand polish including dark mode. Docs restructure, pinned release install URLs, a 1Password release-signing guide, and README/CHANGELOG coverage of all changes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 175 ++++++-- README.md | 714 ++++++++++++++++---------------- SECURITY.md | 6 +- VALIDATION.md | 43 +- VERSION | 2 +- boat-chat/README.md | 19 +- boat-chat/app.py | 60 ++- boat-chat/static/app.js | 431 +++++++++++++------ boat-chat/static/index.html | 101 +++-- boat-chat/static/styles.css | 309 +++++--------- control-panel/app.py | 30 +- control-panel/static/app.js | 101 +++-- control-panel/static/index.html | 51 +-- control-panel/static/styles.css | 133 +++--- docs/BOAT_CHAT.md | 36 +- docs/BOM.md | 1 + docs/CONTROL_PANEL.md | 2 +- docs/CONTROL_PANEL_TESTING.md | 26 +- docs/DEPLOYMENT.md | 173 +++++--- docs/DEVELOPMENT.md | 109 ++++- docs/DISPLAYS.md | 92 ++-- docs/GETTING_STARTED.md | 30 +- docs/INSTALLATION.md | 45 +- docs/MQTT.md | 6 +- docs/NETWORK.md | 14 +- docs/README.md | 10 +- docs/UPDATING.md | 128 +++--- docs/VESSELTWIN.md | 4 +- install.sh | 412 ++++++++++-------- scripts/get-vesselstack.sh | 4 +- tests/test-clean-install.sh | 41 ++ tests/test_access_security.py | 88 ++++ tests/test_control_panel.py | 13 + tests/test_control_panel_ui.cjs | 33 +- tests/test_env_quoting.py | 29 +- tests/test_wizard.py | 49 ++- vesselstack-wizard.py | 5 +- wizard/app.js | 52 ++- wizard/index.html | 4 +- wizard/styles.css | 59 ++- 40 files changed, 2227 insertions(+), 1413 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d703200..d714031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,51 +3,136 @@ All notable changes to VesselStack are documented here. The project follows Semantic Versioning. -## Unreleased - -- Docs: `README.md` is now a landing page with a table of contents and a task-to-document - table. Deep reference moved unchanged into `docs/` (`DISPLAYS.md`, `BOAT_CHAT.md`, - `CONTROL_PANEL.md`, `MQTT.md`, `NETWORK.md`, `UPDATING.md`; developer validation and - historical notes in `DEVELOPMENT.md`). Install, backup, removal and troubleshooting - stay in the README and its anchors are preserved. `tests/test-distribution.sh` now - checks the pinned ESPHome digest and version in README or `docs/DISPLAYS.md`. -- Displays: Home Assistant numeric items now round like instruments (half away - from zero, no `-0.0`, integer payload at precision 0, out-of-range values are - "missing"); a heading that rounds up to 360 shows 0; the board registry - validator rejects a board that lists a layout the firmware generator cannot - render (`list-6` stays defined but unoffered); `displays/README.md` lists - `preflight` and documents adding a board versus adding a layout. -- Installer env files (`vesselstack.env`, `boat-chat.env`, `control-panel.env`) - are written with portable quoting that bash, systemd, Python and Docker - Compose read identically; values with spaces, `$` or `!` no longer split or - expand. Values with an apostrophe plus a backslash, double quote, `$` or - backtick, and multi-line values, are refused. -- The installed `vesselstackctl` defaults to the configured `VESSELSTACK_ROOT`, - and `uninstall` also removes `vesselstack-update` and `deploy-chat.py`. -- Boat Chat and Control Panel token comparison handles non-ASCII header text; - `GET`/`HEAD /api/settings` require the access token; unauthenticated loopback - settings writes require a local `Host` name (DNS-rebinding defense). -- Power tracking splits sign-crossing intervals at zero and floors peak - inferred charge at 0 W. -- The VesselTwin preload now pins `signalk-vesseltwin` 0.2.3 (0.2.2 was never - published). An existing preloaded copy is never overwritten; existing boats - update through the Signal K Appstore. In Docker mode, a re-run that newly - preloads the plugin restarts the running `signalk` container (the helper exits - with status 10 when it installed something). `docs/VESSELTWIN.md` now lists - plugin enablement as its own step for preloaded and Appstore installs, explains - how to reach the admin page, and matches the plugin's status messages. -- The VesselTwin preload also lists `signalk-vesseltwin` in - `signalk/package.json` dependencies so Signal K App Store installs do not - prune it. -- Documentation corrections: README structure, version references, VesselTwin - wording, and historical 1.0 scope and validation labels. -- Documentation: the hardware guide now recommends the Copperhill/SK Pang - PiCAN-M as the NMEA 2000 HAT, with power guidance per Pi model and USB and - ESP32 alternatives. Galvanically isolated interfaces are listed first as - preferred (safest); the non-isolated PiCAN-M is documented as field-proven - with explicit grounding caveats. Wizard text (terminal and browser) now - states the same preferred/caveats framing. Installation docs add a PiCAN-M - `config.txt` example. +## 0.6.0 - 2026-11-02 + +### Highlights + +- Setup wizard: the "Nautical" units option is gone. It produced a configuration the installer + refused; the choices are now US customary (the default, matching the installer) and metric, + and knots and nautical miles are always kept. The web wizard asks for the Signal K address + when you choose "use existing" and has an untrusted-interface field when the firewall is on. + Back keeps your selections, Continue is blocked until the page has its address token, and + after saving it shows the next commands to run. It also gains dark mode and screen-reader + support. +- Control Panel: Start, Restart, Stop and Remove ask for confirmation using the component's + name ("Live instrument data will pause briefly"), there is an "Apply now" button, error + messages stay until you dismiss them, and the rollback path stays visible. It also gains dark + mode, accessibility improvements, "Signal K" naming and plainer labels. Stored keys and values + are unchanged. +- Boat Chat: the credentials are now called "Access code" and "Admin code" in the interface + (the environment variable names are unchanged). Phones get a "More" menu, and the header and + overview status agree. Chat requests time out after 3 minutes, Clear is a two-step action, + notes and feedback use inline forms, and the trip report downloads as + `vesselstack-trip-.txt`. A unified brand palette, dark mode and screen-reader + announcements complete the update. +- Installer: every value written to the env files is checked first, before anything changes and + also under `--dry-run`, and the env files are written atomically. +- The README is a short landing page with a table of contents, a "What you need" hardware + list and a table that points each task to the right guide. Deep reference lives in `docs/` + (`DISPLAYS.md`, `BOAT_CHAT.md`, `CONTROL_PANEL.md`, `MQTT.md`, `NETWORK.md`, `UPDATING.md`; + developer validation, wizard internals and historical notes in `DEVELOPMENT.md`). Install, + backup, removal and troubleshooting stay in the README. The README sections that moved to + `docs/` are listed under "Moved sections" at its end, with links. +- Every quick start now uses the pinned release URL. GitHub's "Latest" release is still + `v0.4.4` (every 0.5.x release was a prerelease), so `releases/latest/download/` installs 0.4.4; + it is documented only as a secondary form. The documented default for reaching the Control + Panel is an SSH tunnel, with direct network access as an opt-in choice. +- The README gains an "Upgrading to 0.6.0" section: what changes for an existing boat, how the + installer's env-value check behaves, verification, and rollback. +- Hardware guide: the NMEA 2000 section recommends the Copperhill/SK Pang PiCAN-M as a + field-proven HAT, with power guidance per Pi model and USB and ESP32 alternatives. + Galvanically isolated interfaces are listed first as preferred (safest); the non-isolated + PiCAN-M is documented with explicit grounding caveats. The terminal and browser wizards say + the same, and the installation guide adds a PiCAN-M `config.txt` example. The optional + Waveshare ESP32-S3-Touch-LCD-2.8C display is listed in the bill of materials as a preview item. + +### Fixes + +- Boat Chat: a Settings save could blank the server configuration when settings had failed to + load. Settings no longer opens without loading, and it saves only the fields you changed. + Saves that blank `BOAT_CHAT_PROVIDER`, `BOAT_CHAT_MODEL`, `BOAT_CHAT_MAX_TOKENS`, + `BOAT_CHAT_CONTEXT_CHARS`, `BOAT_CHAT_HOST` or `BOAT_CHAT_PORT`, or give invalid numbers, are + rejected with a 400 (an explicit `null` resets the setting to its default). +- Boat Chat: the access code can no longer be cleared (blank or `null` for + `BOAT_CHAT_ACCESS_TOKEN` is rejected with a 400) unless `BOAT_CHAT_ALLOW_OPEN=true`, so an + API-only save cannot lock every client out. Set a new code instead. +- Installer: an unsafe env value used to be refused only during `--start`, after services were + stopped and files replaced, and the installed `vesselstack.env` had already been truncated. + The check now runs first (see Security), and each env file is written to a temporary file in + the same directory (mode 0600) and renamed into place only when complete, so a failure leaves + the previous file intact. Ownership and permissions are unchanged. The installer also removes + stale `.vesselstack.env.*`, `.boat-chat.env.*` and `.control-panel.env.*` temp files (exact + `mktemp` names only) from `config/` left by a killed run, and the preflight now also checks + the preserved or generated secrets. +- Displays: Home Assistant numeric items round like instruments (half away from zero, no + `-0.0`, an integer payload at precision 0, out-of-range values count as missing). A heading + that rounds up to 360 shows 0. +- Installer env files (`vesselstack.env`, `boat-chat.env`, `control-panel.env`) are written with + quoting that bash, systemd, Python and Docker Compose read identically, so values with spaces, + `$` or `!` no longer split or expand. +- The installed `vesselstackctl` defaults to the configured `VESSELSTACK_ROOT`, and `uninstall` + also removes `vesselstack-update` and `deploy-chat.py`. +- Power tracking splits intervals that cross zero and floors peak inferred charge at 0 W. +- VesselTwin preload: the installer pins `signalk-vesseltwin` 0.2.3 (0.2.2 was never + published) and also lists it in `signalk/package.json` dependencies so Signal K App Store + installs do not prune it. An existing preloaded copy is never overwritten; existing boats update + through the Signal K App Store. `docs/VESSELTWIN.md` lists plugin enablement as its own step + for preloaded and App Store installs, explains how to reach the admin page, and matches the + plugin's status messages. +- Documentation corrections: README structure, version references, VesselTwin wording, the + historical 1.0 scope and validation labels, the screenshot captions (they show the optional + browser wizard), and the "signed release" wording, which is now "pinned release". + +### Security + +- `GET`, `HEAD` and `POST /api/settings` require the Boat Chat access code, and `POST` also + requires the admin code where one is configured. The new optional `X-Boat-Chat-Access-Token` + header carries the access code when it differs from the admin code, on `GET`, `HEAD` and + `POST`. +- Unauthenticated loopback settings writes require a local `Host` name (DNS-rebinding defense). +- Boat Chat and Control Panel token comparison handles non-ASCII header text. +- Env-file values that contain an apostrophe together with a backslash, double quote, `$` or + backtick, and multi-line values, are refused by the installer instead of being written in a + form some readers would misparse. A preflight checks every value for `vesselstack.env`, + `boat-chat.env` and `control-panel.env` before anything changes, including under + `--dry-run`, and names only the offending keys, never the values. + +### Upgrade notes + +- `install.sh --dry-run`, and so `vesselstackctl update` and the Control Panel updater, refuses + the unsafe env values above before the backup is taken or anything is changed, naming the key. + The fix is in the README under "Upgrading to 0.6.0". +- Docker Signal K may restart once: a re-run that newly preloads the VesselTwin plugin restarts + the running `signalk` container (the helper exits with status 10 when it installed something). +- Anything that read `/api/settings` without the access code now needs it, and scripts that call + `POST /api/settings` need the access code (plus the admin code where configured). Send the + access code in `X-Boat-Chat-Access-Token` when it differs from the admin code. +- Owners on 0.4.x: update once from the CLI with the pinned installer. The 0.5.0 Mosquitto access + list cuts off MQTT users that were added by hand to the old password file. +- Owners on 0.5.5 to 0.5.7 can use the Control Panel updater; enable "Include lab prereleases" if + 0.6.0 is published as a prerelease. + +### Rollback + +Downgrades are refused. Restore the verified pre-update backup that the update takes: +`sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes`. See the README sections +"Updating" and "Backups". + +### Release status + +RELEASE-STATUS-PENDING + +### For contributors + +- `tests/test-distribution.sh` checks the pinned ESPHome digest and version in `README.md` or + `docs/DISPLAYS.md`. +- The board registry validator rejects a board that lists a layout the firmware generator cannot + render (`list-6` stays defined but unoffered); `displays/README.md` lists `preflight` and + documents adding a board versus adding a layout. +- In Docker mode the VesselTwin preload helper exits with status 10 when it installed something; + the installer uses that to restart the `signalk` container. +- Wizard internals moved from the README to `docs/DEVELOPMENT.md` ("Wizard internals"). +- Version bump: `VERSION` and `DEFAULT_VERSION` in `scripts/get-vesselstack.sh` are 0.6.0. ## 0.5.7 - 2026-11-01 diff --git a/README.md b/README.md index c3da586..184990e 100644 --- a/README.md +++ b/README.md @@ -9,34 +9,58 @@ the boat's local network when the internet is unavailable. ## Contents -- [Where to go next](#where-to-go-next) -- [What you get](#what-you-get) -- [Optional integrations](#optional-integrations): VesselTwin and instrument displays -- [Quick start for an internet-connected Pi](#quick-start-for-an-internet-connected-pi) -- [Quick start: one command on the Raspberry Pi](#quick-start-one-command-on-the-raspberry-pi) and [manual guided installation](#advanced-manual-guided-installation) +- [What you need](#what-you-need) and [where to go next](#where-to-go-next) +- [What you get](#what-you-get) and [optional integrations](#optional-integrations): VesselTwin and instrument displays +- [Quick start: one command on the Raspberry Pi](#quick-start-one-command-on-the-raspberry-pi), [before and after installation](#before-and-after-installation) and [manual guided installation](#advanced-manual-guided-installation) +- [Upgrading to 0.6.0](#upgrading-to-060) - [First-run setup](#9-complete-first-run-setup): Home Assistant, Signal K, InfluxDB, Grafana, Boat Chat, Control Panel - [MQTT access control](#mqtt-access-control), [network access](#reaching-vesselstack-from-other-devices) and [network security](#network-security) - [Backups](#backups), [Updating](#updating), [Stopping or removing services](#stopping-or-removing-services) - [Troubleshooting](#troubleshooting) -- [Developer validation](#developer-validation), [Documentation policy](#documentation-policy), [License and security](#license-and-security) +- [Developer validation](#developer-validation), [Documentation policy](#documentation-policy), [License and security](#license-and-security), [Moved sections](#moved-sections) + +## What you need + +The reference build, from [docs/BOM.md](docs/BOM.md): + +- Raspberry Pi 5 (8 GB; 4 GB is the minimum) with the Raspberry Pi Active Cooler. +- A 256 GB or larger high-endurance USB 3 SSD for the data disk. +- A dedicated, fused 12 V DC branch feeding an isolated or regulated marine + supply that delivers a stable 5 V / 5 A to the Pi (the BOM allows a 10-32 V + DC input). +- An NMEA 2000 interface, if you want boat data from the bus. A galvanically + isolated interface is preferred; the PiCAN-M is field-proven on a boat but is + not isolated and comes with grounding caveats. +- A separate backup drive, mounted outside the data disk. +- Optional: one Waveshare ESP32-S3-Touch-LCD-2.8C instrument display (a preview + feature, see [Instrument displays](docs/DISPLAYS.md)). + +A Raspberry Pi 4 also works, and a Debian or Ubuntu host with systemd is +supported. Setup needs the internet once; afterwards the stack runs on the boat +network without it. ## Where to go next | I want to… | Read | |---|---| | Understand the hardware and buy parts | [Hardware bill of materials](docs/BOM.md) | -| Install on a new or existing Pi | [Quick start](#quick-start-one-command-on-the-raspberry-pi), then [Installation](docs/INSTALLATION.md) | +| Build a new Pi | [Hardware bill of materials](docs/BOM.md), then [Installation](docs/INSTALLATION.md) | +| Add VesselStack to an existing Pi | [Quick start](#quick-start-one-command-on-the-raspberry-pi), then [Getting started](docs/GETTING_STARTED.md) | | Do first boot, SSH and token steps | [Getting started](docs/GETTING_STARTED.md) | | Use or secure the Control Panel | [Control Panel](docs/CONTROL_PANEL.md) | | Set up Boat Chat, units and timezone | [Boat Chat setup](docs/BOAT_CHAT.md), providers in [Boat Chat providers](boat-chat/README.md) | | Reach dashboards from other devices | [Network access and security](docs/NETWORK.md) | | Manage MQTT accounts and access lists | [MQTT access control](docs/MQTT.md) | -| Update, roll back, or install offline | [Updating and offline install](docs/UPDATING.md), [Deployment runbook](docs/DEPLOYMENT.md) | +| Update, roll back, or install offline | [Updating](#updating), [Updating and offline install](docs/UPDATING.md) | +| Release to a lab Pi and an offline boat | [Deployment runbook](docs/DEPLOYMENT.md) | | Connect VesselTwin | [VesselTwin integration](docs/VESSELTWIN.md) | | Add ESP32 instrument displays | [Instrument displays (preview)](docs/DISPLAYS.md) | | Develop, test, or cut a release | [Development guide](docs/DEVELOPMENT.md) | | See every document | [Documentation index](docs/README.md) | +Power sources and instrument-reading coverage for Boat Chat are in +[Power monitoring](boat-chat/POWER_MONITORING.md). + ## What you get - A local view of boat data from Signal K-compatible sources and supported NMEA @@ -87,7 +111,7 @@ These features are off or inert until you deliberately enable them. Neither is n ### Optional VesselTwin connection An optional VesselTwin connection uses the `signalk-vesseltwin` plugin, pairing, -and explicit reading opt-ins. Managed Docker/native SignalK modes preload the pinned +and explicit reading opt-ins. Managed Docker/native Signal K modes preload the pinned plugin by default (0.5.x lab prerelease); VesselStack does not enable, pair, or authorize readings for you. See the [VesselTwin integration guide](docs/VESSELTWIN.md) for setup and [VesselTwin](https://www.vesseltwin.io/) for maintenance documents, @@ -108,254 +132,72 @@ sudo vesselstackctl display status Lifecycle commands are `vesselstackctl display list|add|remove|rotate|wifi|status|render|export|build|flash`. See [Instrument displays (preview)](docs/DISPLAYS.md) for setup, firmware, layouts, the pinned ESPHome version and limitations, and [displays/README.md](displays/README.md) for the code layout. -## Choose your path - -| If you are… | Start here | -|---|---| -| Building a new Pi | [Hardware bill of materials](docs/BOM.md), then the [installation guide](docs/INSTALLATION.md) | -| Adding VesselStack to an existing Pi | [Getting started](docs/GETTING_STARTED.md), then [installation](docs/INSTALLATION.md) | -| Releasing to a lab and an offline boat | [Deployment runbook](docs/DEPLOYMENT.md) | -| Developing or validating changes | [Development guide](docs/DEVELOPMENT.md) | - -## Quick start for an internet-connected Pi +## Quick start: one command on the Raspberry Pi -The normal first install is headless: the Pi needs no screen, keyboard, or -browser. From your laptop, SSH to the Pi with a terminal allocation, then run -the release installer and answer its short questions: +**Headless is the normal case: the Pi needs no screen, keyboard or browser.** On a +Raspberry Pi running 64-bit Raspberry Pi OS (or Debian) with internet access, SSH into +it from your laptop (use `ssh -t` so the installer can ask its questions on that +session), run the pinned release installer and answer the questions in the same terminal: ```bash ssh -t YOUR_USER@raspberrypi.local -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` -The installer checks the host before changing it. It may install Debian -prerequisites and, after asking, Docker Engine and Compose v2 from Docker's -official apt repository. It then downloads and checksum-verifies the release, -asks for boat and service settings, and offers a final review before starting -VesselStack. `sudo` is required because installation writes system packages, -services, and `/opt/vesselstack`; the questions explain each change. Secrets -are generated or entered without echo and are not printed in the review. - -The command above installs the latest stable release. For the `0.5.7` lab -prerelease, follow the pinned-release instructions in the -[installation guide](docs/INSTALLATION.md). -For a boat with no internet, prepare the release and offline bundle in the lab -and follow the [offline deployment path](docs/DEPLOYMENT.md). - -## Before and after installation - -Before installation, decide where the Pi will live, how it will be powered, -and whether its data disk and backups are separate from the boot media. If you -connect to NMEA 2000, confirm the bus is correctly powered and terminated and -that you have chosen the interface and its power source deliberately -(an isolated interface is preferred (safest); a non-isolated one such as the -PiCAN-M works with caveats, see docs/BOM.md). Commission on the bench first. - -After installation, use the printed URLs from a trusted local device, run -`sudo vesselstackctl doctor`, and confirm the dashboards show the signals you expect. -Keep the generated credentials per host, schedule backups to a separate device, -and test restoring one before relying on the system. Read the deployment guide -before carrying an update to a boat without internet access. - -### Update from the Control Panel (0.5.7 lab release) - -An authenticated Control Panel session can check releases from the public -`jclima/VesselStack` repository. Stable releases are shown by default; enable -**Include lab prereleases** to include lab releases in the list. The panel shows -the installed/running version, release channel, -release notes, and a link to the release; it does not display a checksum value. -It accepts no arbitrary URL or filesystem path and does -not let the browser override the backup destination. - -Before an update, mount the configured external backup device under `/media` or -`/mnt` and confirm the displayed path is the one you intend. The panel's **Back -up & install** flow -asks for confirmation, verifies the downloaded SHA-256 manifest, safely extracts -the release, runs preflight, then makes and verifies the backup. A failed or -unmounted backup stops the update; the backup path and bounded operation log -remain available for recovery. There is no automatic rollback promise: use the -explicit CLI `vesselstackctl verify-backup` and `vesselstackctl restore` path in -the deployment guide when recovery is needed. `--require-mounted` defeats -`ALLOW_UNMOUNTED` for this flow. - -The detached updater restarts the Control Panel and verifies its new version and -boot before the browser reconnects. Settings also provides a standalone -**Restart Control Panel** action. The panel uses ordinary HTTP; release -checksums provide corruption detection, not signed provenance, and the 0.5.7 -lab tag is explicitly unsigned and not latest. See the full reference below for -backup, recovery, and update details. - -The browser updater (0.5.x lab prerelease) was introduced in `0.5.5`. Installations on `0.5.4` or -earlier must use the CLI installer/update once; installations on `0.5.5` or -`0.5.6` can upgrade to `0.5.7` through the Control Panel with lab prereleases -enabled. -The panel serializes its own update/configuration/component operations, but -unrelated CLI commands are separate maintenance actions and are not globally -serialized by the panel; do not run them concurrently with an update. - -The strict backup flag requires a mounted external destination under `/media` -or `/mnt`; browser input cannot override that destination. The transient -updater job, status, and staging files under `/var/lib/vesselstack` are -intentionally excluded from backups; user configuration and data are backed up. -If an update fails, the verified pre-update backup is kept for explicit -`vesselstackctl verify-backup`/`restore` recovery. The browser does not promise -automatic restoration. - -## Find your next answer - -The [documentation index](docs/README.md) maps common tasks to the right guide, -including first boot, access, offline operation, troubleshooting, privacy, and -contribution. This README keeps the install steps, backups, removal and -troubleshooting; deeper reference lives in `docs/`. For Boat Chat provider -configuration see [Boat Chat providers](boat-chat/README.md); for first-run access, settings, -timezone and units see [Boat Chat setup](docs/BOAT_CHAT.md); for power sources and -instrument-reading coverage see [Power monitoring](boat-chat/POWER_MONITORING.md). - -## Quick start: one command on the Raspberry Pi - -**Docker is installed for you.** If Docker Engine with the Compose v2 plugin is missing, -the script asks once and then follows Docker's official apt-repository instructions -(; the Ubuntu page for Ubuntu): - -- it checks the system first: 64-bit Raspberry Pi OS, Debian 12 (bookworm) or 13 - (trixie), or Ubuntu; **32-bit Raspberry Pi OS (armhf) is refused** (see below); -- it downloads Docker's signing key over HTTPS, checks that the file holds exactly - one key and that its fingerprint is `9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88`, - and stops without changing anything if it does not match; -- it installs the key as `/etc/apt/keyrings/docker.asc`, writes - `/etc/apt/sources.list.d/docker.sources` (Docker's deb822 entry for your release and - CPU), runs `apt-get update` and installs `docker-ce docker-ce-cli containerd.io - docker-buildx-plugin docker-compose-plugin`; it enables and starts the `docker` - service and verifies `docker --version`, `docker compose version` (v2 or newer) and - `docker info` (nothing is pulled); -- if Docker is already installed but the Compose plugin is missing, only - `docker-compose-plugin` is added from the same repository; -- if Docker and Compose v2 are already present, it does nothing to Docker; -- unofficial packages that conflict with Docker's own (`docker.io`, `docker-cli`, - `docker-compose`, `docker-compose-v2`, `docker-doc`, `docker-buildx`, - `podman-docker`, `containerd`, `runc`) are **never removed silently**: the script - lists the ones that are installed (plus any dependent packages apt would remove with - them, taken from `apt-get -s remove`) and asks; `--yes` accepts. They are removed with - `apt-get remove` (not purge), so `/var/lib/docker` is kept. If a Docker daemon - already holds containers or volumes, `--yes` is not enough: the script stops, and you - must confirm interactively (back up first) or remove the packages yourself; -- an older `docker.list` (the manual steps of v0.4.0) or any other apt source that - already points at `download.docker.com` is renamed to `*.vesselstack-saved` after - asking, because two entries with different keys make `apt-get update` fail; -- no `docker` group change is made: the installer runs as root and VesselStack's - service account does not need Docker access; -- `--offline` cannot install Docker; install it first while online. - -If a step fails, the script prints the failing command and what it left behind; -re-running is safe. **32-bit Raspberry Pi OS:** Docker Engine v28 was the last release -for it, so VesselStack needs 64-bit Raspberry Pi OS. Re-flash the card or SSD with -"Raspberry Pi OS (64-bit)" in Raspberry Pi Imager (Pi 3, 4, 5 and Zero 2 W support it); -`dpkg --print-architecture` must say `arm64` (`uname -m` can say `aarch64` on a 32-bit -system running a 64-bit kernel). - -To undo the Docker part (your containers and data stay unless you delete them): +The same release is also selected by `VESSELSTACK_VERSION=0.6.0` or `--version 0.6.0`: ```bash -sudo apt-get remove docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -sudo rm -f /etc/apt/sources.list.d/docker.sources /etc/apt/keyrings/docker.asc -sudo apt-get update -# optional, deletes ALL images, containers and volumes: sudo rm -rf /var/lib/docker /var/lib/containerd +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo VESSELSTACK_VERSION=0.6.0 bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash -s -- --version 0.6.0 ``` -**Headless is the normal case: the Pi needs no screen, keyboard or browser.** On a -Raspberry Pi running 64-bit Raspberry Pi OS (or Debian) with internet access, SSH into -it from your laptop (use `ssh -t` so the installer can ask its questions on that -session), paste this one line and answer the questions in the same terminal: - -```bash -ssh -t YOUR_USER@raspberrypi.local -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash -``` +> [!NOTE] +> The pinned URL names the release explicitly, so it installs exactly 0.6.0 whatever +> GitHub marks as Latest. The form `https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh` +> points to whichever release GitHub marks Latest, which is not necessarily the newest +> one (every 0.5.x release was published as a prerelease, so that URL kept serving +> 0.4.4). Use it only when you want exactly that. -(Or paste the second line into a terminal on the Pi itself. No address is opened and no +The installer checks the host before changing it. It may install Debian +prerequisites and, after asking, Docker Engine and Compose v2 from Docker's +official apt repository (signing-key fingerprint checked; details and undo steps are in +[the installation guide](docs/INSTALLATION.md#quick-path-one-command)). It then downloads and +checksum-verifies the release, asks for boat and service settings, and offers a final review +before starting VesselStack. `sudo` is required because installation writes system packages, +services, and `/opt/vesselstack`; the questions explain each change. Secrets are generated or +entered without echo and are not printed in the review. **32-bit Raspberry Pi OS (armhf) is +refused**: Docker Engine v28 was the last release for it. Re-flash with "Raspberry Pi OS +(64-bit)" in Raspberry Pi Imager (VesselStack supports Raspberry Pi 4 and 5); +`dpkg --print-architecture` must say `arm64`. + +(Or paste the command into a terminal on the Pi itself. No address is opened and no tunnel is needed.) The questions are grouped in short sections, each with a plain-language explanation and a sensible default in `[brackets]`: **Enter** accepts the default, **?** explains the question, **b** goes back one question, and a final review screen lists every -choice (never any secret) with *save*, *edit a section* and *quit*. Example, abridged: - -```text -== Step 5 of 6: Configure == - Starting the setup questions. Press Enter to accept the answer in [brackets]; ? explains a question. - -VesselStack setup. Press Enter to accept the answer in [brackets]. ? explains a question, b goes back one question. -Everything stays on this computer; no secrets are shown or printed. -This computer: aarch64, 7.6 GiB RAM, Docker ready; CAN: no can0 yet; network: eth0, wlan0. - -[1/9] Your boat - Names shown in dashboards and Boat Chat. Everything stays on this Raspberry Pi. - Boat name [My Boat]: Sea Wolf - Boat type [Motor vessel]: Sailing yacht - 1) metric metres, km/h, degrees C - 2) us_customary feet, mph, degrees F - 3) nautical knots, nautical miles, metres/fathoms - Units - choose 1-3 [metric]: 3 - Detected from this computer: Europe/Oslo - Timezone [Europe/Oslo]: - Add optional vessel details (MMSI, call sign, length, beam)? [y/N]: - -[2/9] Signal K (the boat data hub) - How should Signal K run? - choose 1-3 [docker]: - -[3/9] NMEA 2000 (CAN bus) - No can0 interface found yet (normal before the CAN HAT overlay is installed). You can enable it later. - Enable SocketCAN / NMEA 2000? [y/N]: ? - An isolated CAN interface is preferred (safest); a non-isolated one such as the PiCAN-M works with caveats (see docs/BOM.md). Needs a powered and terminated backbone and the can0 device present. ... - Enable SocketCAN / NMEA 2000? [y/N]: -... -Advanced settings (service account, folders, ports, container and bucket names, passwords)? [y/N]: - -Review (secrets are never shown): - 1. Your boat - Boat name Sea Wolf - Units nautical - ... - [s] save and continue [e] edit a section [q] quit without saving - choose [s]: - - Install and start VesselStack now? [Y/n]: -... - Addresses (for information; use them from any computer on the boat network when you like): - Grafana (history dashboards) http://192.0.2.10:43000 - Home Assistant http://192.0.2.10:8123 - Boat Chat http://192.0.2.10:8765 - Show the Boat Chat access token (it is printed only when you run this): - sudo grep '^BOAT_CHAT_ACCESS_TOKEN=' /opt/vesselstack/config/boat-chat.env - Control panel (administration; it listens on this Pi only, port 8780). From your own computer: - ssh -L 8780:127.0.0.1:8780 YOUR_USER@192.0.2.10 -``` - -Everything printed at the end is information only: nothing in the install needs a -browser, and nothing is opened for you. +choice (never any secret) with *save*, *edit a section* and *quit*. A short example is in +[the installation guide](docs/INSTALLATION.md#quick-path-one-command). At the end the +installer prints the Grafana, Home Assistant and Boat Chat addresses, the command that shows +the Boat Chat access token, and the SSH tunnel command for the Control Panel. Everything +printed at the end is information only: nothing in the install needs a browser, and nothing is +opened for you. -The `releases/latest/download/` form works only once a release carrying -`get-vesselstack.sh` has been published **and marked latest**. Until then (and while a -release is still being install-tested) use the pinned form shown next, which works for -any published release, including a pre-release. +For a boat with no internet, prepare the release and offline bundle in the lab and follow the +[offline deployment path](docs/DEPLOYMENT.md#simple-path-one-command). Releases are built by the tag-triggered GitHub workflow (read-only build job, separate write-only publish job, draft first, reproducible tarball on Linux); maintainers follow the -release checklist in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). - -To install an exact release instead (recommended once you have tested one): - -```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh | sudo bash -# or: curl -fsSL .../get-vesselstack.sh | sudo VESSELSTACK_VERSION=0.5.7 bash -# or: curl -fsSL .../get-vesselstack.sh | sudo bash -s -- --version 0.5.7 -``` +release checklist in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-checklist). The recommended +way to sign release tags (key held in 1Password) is in +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#1-release-signing-with-an-ssh-signing-key). **No prompts at all** (scripts, a Pi without a terminal, repeatable installs): ```bash # defaults, plus the settings you name (repeat --set; names as in vesselstack.example.env) -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh \ +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh \ | sudo bash -s -- --yes --set BOAT_NAME=Foo --set SOCKETCAN_ENABLE=true # every question's own default answer, nothing asked -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash -s -- --defaults +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash -s -- --defaults # a ready-made vesselstack.env (optionally with --set overrides) sudo bash get-vesselstack.sh --yes --config ./vesselstack.env --set BOAT_NAME=Foo ``` @@ -376,14 +218,20 @@ turn on SSH. Boot the Pi, then connect once from your laptop with `ssh -t YOUR_USER@YOUR_PI.local` (use the Pi's router-assigned IP if the name does not resolve). You do not need a screen, keyboard or SSH tunnel on the Pi. -Run the guided installer above in that terminal. In **Who can reach what**, choose -**Trusted network — token required** for the Control Panel. This choice is separate -from the dashboard preset: choose **local** for the dashboards if you want their -managed listeners kept on the Pi. The installer prints the panel's real address, -such as `http://YOUR_PI.local:8780`, and the command to retrieve its unlock token. -Open that address in your laptop's browser and paste the token into **Unlock**. +Run the guided installer above in that terminal. In **Who can reach what**, the +question **Control panel listens on** defaults to **this Pi only (SSH tunnel)**. Keep +that answer: it is the recommended setting. The installer ends by printing the tunnel +command; run it on your own computer, open `http://127.0.0.1:8780`, and paste the +panel token into **Unlock**: -For an existing installation with this command available: +```bash +ssh -L 8780:127.0.0.1:8780 YOUR_USER@YOUR_PI.local +sudo grep '^CONTROL_PANEL_TOKEN=' /opt/vesselstack/config/control-panel.env +``` + +Reaching the panel directly over the boat network is an opt-in choice for a trusted +lab or boat LAN: answer **trusted network (token required)** instead, or apply it to an +installed system with this command and use the address it prints: ```bash sudo vesselstackctl panel-access lan @@ -411,7 +259,7 @@ Network access binds the panel to **all IPv4 interfaces**. Use it only on a trus lab or boat network: its token is still required, but ordinary HTTP does not encrypt administration traffic. Do not forward this port from the internet. An enabled strict firewall may need an explicit rule for the panel port on the relevant -interface; setup never silently opens firewall ports. Local-only remains the default. +interface; setup never silently opens firewall ports. Local-only (SSH tunnel) is the default and the recommendation. The guided installer explains each step in plain language and asks before it does anything beyond installing ordinary Debian packages: @@ -434,46 +282,22 @@ does anything beyond installing ordinary Debian packages: 4. asks the configuration questions **in the terminal** (the terminal wizard, `vesselstack-wizard.py --cli`, the default). It reads your answers from the terminal (`/dev/tty`, so the piped `curl | sudo bash` form works), validates every answer - immediately with the same rules as the browser wizard (`DEFAULTS`, `validate()`, - `render()`; stricter checks only where `install.sh` enforces the same limits, such as - the boat name characters), and offers sections for the boat (name, type, units, timezone - detected from the system, optional and private MMSI, call sign, length and beam), - Signal K (docker, existing or native; docker recommended), NMEA 2000 / CAN - (`SOCKETCAN_ENABLE`, interface, bit rate and the home-lab virtual-CAN option - `VESSELSTACK_CAN_CREATE_VCAN`), AIS, Home Assistant (URL, optional hidden token), Boat - Chat provider (default `local`) and Telegram, the firewall (untrusted interface, enable, - strict mode, allowed ports), raw-data retention (`INFLUXDB_RAW_RETENTION`; empty means - 30 days on a new install and no change to an existing database) and who can reach the - dashboards (`local`, `dashboards` or `all`; see "Reaching VesselStack from other devices"). - Ports, container and bucket names, folders, the service account and the generated - secrets sit behind "Advanced settings? [y/N]". Passwords and tokens default to being - generated during installation, are read without echo when you do type one and are - never printed or logged. It detects the architecture, Docker, `can0` and the network - interface names to inform defaults and hints; it never turns hardware on by itself. - The browser wizard asks the same "Who should be able to reach the dashboards?" question - (`local`, `dashboards` default, `all`) on its network step. The page sends only the - preset name (`LAN_ACCESS`); the server validates it (an unknown name is refused with HTTP 400, - nothing is written) and expands it with the terminal wizard's `LAN_PRESETS` mapping - into `VESSELSTACK_WEB_BIND_ADDRESS`, `VESSELSTACK_DATA_BIND_ADDRESS`, `BOAT_CHAT_HOST` and `HOMEASSISTANT_BIND_ADDRESS`; - the preset wins over explicit bind values, `LAN_ACCESS` is never written to the env file - and the independent Control Panel access choice is preserved. Choosing `all` shows the warning that Prometheus - has no login and recommends the firewall. The loopback, Host/Origin, URL-fragment token - and content-type protections are unchanged. - The terminal wizard has no network surface (no web server, no token, no URL), so it - runs as root directly, in a private root-only `0700` directory; its output is then - treated exactly like the browser wizard's: copied once into the root-only staging - directory, linted, re-validated as root with `vesselstack-wizard.py --validate FILE` - (known settings only, allowed characters, enum and boolean values) and installed - root-only, mode 600. `--config FILE` gets the same treatment (opened without following - symlinks, a regular file of at most 64 KiB, copied once, only the staged copy linted, - validated and used); with `--set` the staged copy is the base the values are applied - to. Optional browser wizard: add `--web-wizard`; it is unchanged (loopback only on a - random free port, `--wizard-port N`, run as an unprivileged user or the temporary - `vesselstack-wizard` system account, a `#token=` address printed once, open on the Pi - or through the printed `ssh -L` tunnel) and cannot be combined with `--yes`-style - configuration options (`--config`, `--set`, `--defaults`). Limitation: the Docker line - in a wizard's preflight reflects what that wizard sees; `install.sh` performs the - authoritative checks; + immediately, and offers sections for the boat (name, type, units, timezone detected + from the system, optional and private MMSI, call sign, length and beam), Signal K + (docker, existing or native; docker recommended), NMEA 2000 / CAN (`SOCKETCAN_ENABLE`, + interface, bit rate and the home-lab virtual-CAN option `VESSELSTACK_CAN_CREATE_VCAN`), + AIS, Home Assistant (URL, optional hidden token), Boat Chat provider (default `local`) + and Telegram, the firewall (untrusted interface, enable, strict mode, allowed ports), + raw-data retention (`INFLUXDB_RAW_RETENTION`; empty means 30 days on a new install and no + change to an existing database) and who can reach the dashboards (`local`, `dashboards` + or `all`; see "Reaching VesselStack from other devices"). Ports, container and bucket + names, folders, the service account and the generated secrets sit behind "Advanced + settings? [y/N]". Passwords and tokens default to being generated during installation, are + read without echo when you do type one and are never printed or logged. It never turns + hardware on by itself. The terminal wizard has no network surface. The optional browser + wizard (`--web-wizard`) serves loopback only and cannot be combined with `--config`, + `--set` or `--defaults`. How the wizards validate, stage and install the file is described + in [Development: wizard internals](docs/DEVELOPMENT.md#wizard-internals); 5. shows a summary of the chosen configuration (no secrets), asks for confirmation, runs `install.sh --dry-run`, then `install.sh --start`, then `vesselstackctl doctor`, and prints `vesselstackctl urls` plus, as plain text, the service @@ -512,7 +336,9 @@ script is wrapped in functions and ends with a single `main "$@"` call, so a truncated download cannot half-run. If you prefer to read it first (recommended): ```bash -curl -fsSLO https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256 +sha256sum -c get-vesselstack.sh.sha256 less get-vesselstack.sh sudo bash get-vesselstack.sh ``` @@ -533,42 +359,120 @@ from the end-to-end guide: ```bash git clone https://github.com/jclima/VesselStack.git cd VesselStack -git checkout v0.5.7 +git checkout v0.6.0 python3 vesselstack-wizard.py --cli # terminal questions (no browser) -# python3 vesselstack-wizard.py # or the browser wizard +# python3 vesselstack-wizard.py # or the optional browser wizard ``` The terminal wizard (`--cli`) asks in the terminal and writes `vesselstack.env`; add -`--defaults` and `--set KEY=VALUE` to configure without questions, `--base FILE` to start from an -existing file. For the browser wizard open the address it prints (`http://127.0.0.1:8088/#token=...`), or use the -SSH tunnel documented in the guide and open the same address on your workstation. -The wizard accepts `--port N`, `--output FILE` (where to write the env file) and -`--exit-after-save` (stop after the first successful save; the one-line installer -uses it) and `--validate FILE` (check an existing env file with the wizard's rules, -exit 0 if valid, 1 otherwise, no server started; `VESSELSTACK_CAN_CREATE_VCAN` is now a -known wizard setting, default `false`). Rollback: revert this change; nothing is -persisted except the temporary account, which is removed on exit (`sudo userdel -vesselstack-wizard` if a run was killed with SIGKILL). It prints a per-run address of the form `http://127.0.0.1:PORT/#token=...`; open -that exact address. The token is carried in the URL fragment (never sent to the -server, never present in any served file); `wizard/app.js` reads it, removes it -from the address bar with `history.replaceState` and sends it as `X-Wizard-Token` -with every API call (`/api/system` and `/api/save` refuse requests without it). -The wizard needs no root privileges; run it as a normal user. Requests are refused unless the `Host` -header is `127.0.0.1`/`localhost`/`[::1]` on the bound port, any `Origin` is the -same loopback origin, the token matches, and saves use `Content-Type: -application/json` (this blocks cross-site form posts and DNS rebinding). The env -file is created mode 600 from the start and never through a symlink. Open the -printed URL itself (including the `#token=` part); a bookmarked older address stops working when the wizard restarts. The dependency-free wizard binds only to loopback, shows relevant host -prerequisites, and writes a complete private `vesselstack.env`. It does not -change boot firmware, firewall rules, marine wiring, or start services. Then run -`sudo ./install.sh --config vesselstack.env --dry-run` and `--start` as described -in the sections below. +`--defaults` and `--set KEY=VALUE` to configure without questions, `--base FILE` to start +from an existing file, and `--validate FILE` to check an existing env file with the wizard's +rules (exit 0 if valid, 1 otherwise, no server started). The browser wizard prints a per-run +loopback address of the form `http://127.0.0.1:PORT/#token=...`; open that exact address, on +the Pi or through an `ssh -L` tunnel. Both wizards write a private `vesselstack.env` and +change nothing else: no boot firmware, firewall rules, marine wiring or services. The browser +wizard's options, token handling and request protections are described in +[Development: wizard internals](docs/DEVELOPMENT.md#wizard-internals). Then run +`sudo ./install.sh --config vesselstack.env --dry-run` and `--start` as described in the +sections below. + +Rollback: delete the generated `vesselstack.env`; if a browser-wizard run was killed with +SIGKILL, remove its temporary account with `sudo userdel vesselstack-wizard`. > [!WARNING] > VesselStack is not certified navigation, collision-avoidance, engine-control, > alarm, or life-safety equipment. Maintain independent marine instruments and > alarms. Never depend on this software as the sole source of safety data. +## Before and after installation + +Before installation, decide where the Pi will live, how it will be powered, +and whether its data disk and backups are separate from the boot media. If you +connect to NMEA 2000, confirm the bus is correctly powered and terminated and +that you have chosen the interface and its power source deliberately +(an isolated interface is preferred (safest); a non-isolated one such as the +PiCAN-M works with caveats, see docs/BOM.md). Commission on the bench first. + +After installation, use the printed URLs from a trusted local device, run +`sudo vesselstackctl doctor`, and confirm the dashboards show the signals you expect. +Keep the generated credentials per host, schedule backups to a separate device, +and test restoring one before relying on the system. Read the deployment guide +before carrying an update to a boat without internet access. + +## Upgrading to 0.6.0 + +Read this before updating an installed system. Update from [Updating](#updating) with a backup +on a mounted external drive, not while underway. + +**What changes for an existing boat** + +- **Env-file values are checked more strictly.** The installer refuses a value that holds an + apostrophe together with a backslash, a double quote, a dollar sign or a backtick, and any + multi-line value, in the settings it writes to `vesselstack.env`, `boat-chat.env` and + `control-panel.env`. A boat name such as `Skipper's "Dream"` is affected; `Skipper's Dream` + is not. The check now runs first: before anything is changed, and also under `--dry-run`, and + it covers preserved and generated secrets too. The installer also deletes stale + `.vesselstack.env.*`, `.boat-chat.env.*` and `.control-panel.env.*` temp files from `config/` + (left by a killed run) before writing; no other file is touched. +- **`GET`, `HEAD` and `POST /api/settings` require the Boat Chat access code.** A bookmark, + script or dashboard that read or saved settings without it now receives an authentication + error. Where an admin code is configured, `POST` needs that too. Scripts that call `POST + /api/settings` must send the access code, and, when it differs from the admin code, the new + optional `X-Boat-Chat-Access-Token` header carries it on `GET`, `HEAD` and `POST`. The access + code cannot be cleared through the API (blank or `null` returns 400) unless + `BOAT_CHAT_ALLOW_OPEN=true`. The environment variable names + (`BOAT_CHAT_ACCESS_TOKEN`, `BOAT_CHAT_SETTINGS_TOKEN`) are unchanged. +- **Unauthenticated loopback settings writes require a local `Host` name** (a defense + against DNS rebinding). Reach Boat Chat through `127.0.0.1` or `localhost`, or send the + token. +- **Docker Signal K may restart once.** If the update newly preloads the pinned + `signalk-vesseltwin` 0.2.3 plugin into an already-running Signal K container, the + installer restarts that container so the plugin loads. An existing preloaded copy is never + overwritten. Plan for a short gap in Signal K data. +- **Coming from 0.4.x:** update once from the CLI with the pinned installer. Since 0.5.0 + Mosquitto always enforces an access list, so the first update recreates the broker once, and + MQTT users you added by hand to the old password file lose access unless you add them + through VesselStack (see [MQTT access control](docs/MQTT.md)). + +**Env-file values are checked before anything changes.** `sudo ./install.sh --config FILE +--dry-run`, and therefore `vesselstackctl update` and the Control Panel updater (both run a dry +run first), refuse an unsafe value before the backup is taken, services are stopped or any file +is touched. The installer lists each offending setting by key name only (never the value), says +why, and exits with an error: + +```text + BOAT_NAME: a single quote combined with a backslash, double quote, dollar sign or backtick +Refusing to continue: the settings above cannot be written safely to the environment files. +``` + +Edit that value (for example `BOAT_NAME` in the Control Panel or `vesselstack.env`) so it no +longer combines an apostrophe with one of those characters or spans lines, then run the dry +run or the update again. Env files are also written atomically (to a temporary file that +replaces the old one only when complete), so a failed run leaves the previous file intact. If an +older release left an update stopped on this problem, restore the pre-update backup with the +command under Rollback below, fix the value, and update again. + +**Verify after updating** + +```bash +cat /opt/vesselstack/config/installed-version # 0.6.0 +sudo vesselstackctl doctor # RESULT healthy +sudo vesselstackctl status +``` + +Open Boat Chat and the Control Panel, and confirm the dashboards show live values. + +**Rollback.** Downgrades are refused (`Downgrade from 0.6.0 to ... is not supported`). Restore the +verified pre-update backup instead: + +```bash +sudo vesselstackctl verify-backup /path/to/vesselstack-TIMESTAMP.tar.gz +sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes +``` + +The restore keeps the previous trees as `*.pre-restore-STAMP` until you delete them (see +[Backups](#backups)). + ## What VesselStack installs | Component | Purpose | Default address | @@ -581,7 +485,7 @@ in the sections below. | Heimdall | Local service launcher | `http://HOST` | | Boat Chat | Telemetry-backed diagnostic assistant | `http://HOST:8765` | | Control Panel | Component installation, configuration, and lifecycle | `http://127.0.0.1:8780` | -| Displays bridge (opt-in, `DISPLAYS_ENABLE=true`) | Publishes configured SignalK instruments to MQTT for instrument displays | systemd service `vesselstack-displays-bridge`; no listener | +| Displays bridge (opt-in, `DISPLAYS_ENABLE=true`) | Publishes configured Signal K instruments to MQTT for instrument displays | systemd service `vesselstack-displays-bridge`; no listener | The installer generates credentials, renders a pinned Docker Compose stack, creates a confined systemd service for Boat Chat, creates configurable InfluxDB @@ -595,8 +499,8 @@ configuration, create backups, and apply a reviewed release update. All container tags are release-pinned and checked for Linux ARM64 availability; VesselStack does not rely on floating `latest` tags. -The installer supports an existing SignalK server, an opt-in -pinned SignalK container, or an opt-in pinned native SignalK installation. It +The installer supports an existing Signal K server, an opt-in +pinned Signal K container, or an opt-in pinned native Signal K installation. It also provides hardware-probed SocketCAN and AIS-catcher modules. Hardware modules remain disabled unless explicitly configured. It also includes generic Home Assistant alert/dashboard templates and an optional untrusted-interface @@ -609,7 +513,7 @@ firewall policy. - SSD or NVMe storage strongly recommended - Docker Engine with Docker Compose v2 - Python 3 with `venv` -- Existing SignalK server or a selected VesselStack SignalK install mode +- Existing Signal K server or a selected VesselStack Signal K install mode - Dedicated non-root service account Installation downloads container images and a Python dependency, so temporary @@ -617,10 +521,10 @@ internet access is required. ## Before installation -1. Back up existing Home Assistant, InfluxDB, Grafana, MQTT, and SignalK data. +1. Back up existing Home Assistant, InfluxDB, Grafana, MQTT, and Signal K data. 2. Confirm ports `80`, `443`, `1883`, `8086`, `8123`, `8765`, `8780`, `9090`, and `43000` are free. Port `8780` binds to loopback by default. -3. If using `SIGNALK_MODE=existing`, confirm SignalK responds: +3. If using `SIGNALK_MODE=existing`, confirm Signal K responds: ```bash curl -fsS http://127.0.0.1:3000/signalk @@ -671,16 +575,15 @@ git clone https://github.com/jclima/VesselStack.git cd VesselStack ``` -For the pinned 0.5.7 lab prerelease, use its annotated unsigned tag (it is not -the latest release): +To work from the 0.6.0 release, check out its tag: ```bash git fetch --tags -git checkout v0.5.7 +git checkout v0.6.0 ``` -This lab prerelease is for authorized lab use only; it is unsigned, not latest, -and physical Raspberry Pi testing is pending. It is not a stable release. +0.6.0 release status: recorded in the [CHANGELOG](CHANGELOG.md) at release time. +Check the tag and the release page before relying on either for a production boat. ## 4. Configure the vessel @@ -698,7 +601,7 @@ nano vesselstack.env | `*_PORT` | Host ports for Grafana, Heimdall HTTP/HTTPS, InfluxDB, Prometheus, MQTT, and AIS | | `VESSELSTACK_DATA_BIND_ADDRESS` | IPv4 address InfluxDB, Prometheus and MQTT publish on; default `127.0.0.1` (this device only) | | `VESSELSTACK_WEB_BIND_ADDRESS` | IPv4 address Grafana and Heimdall publish on; default `0.0.0.0` (LAN dashboards), `127.0.0.1` to restrict | -| `HOMEASSISTANT_BIND_ADDRESS` | IPv4 address Home Assistant (host-networked, `8123`) listens on; default `0.0.0.0`. Any other value makes the installer manage one delimited `http:` block in `${VESSELSTACK_DATA}/homeassistant/configuration.yaml` (see "Reaching VesselStack from other devices"). SignalK has no equivalent setting | +| `HOMEASSISTANT_BIND_ADDRESS` | IPv4 address Home Assistant (host-networked, `8123`) listens on; default `0.0.0.0`. Any other value makes the installer manage one delimited `http:` block in `${VESSELSTACK_DATA}/homeassistant/configuration.yaml` (see "Reaching VesselStack from other devices"). Signal K has no equivalent setting | | `BOAT_CHAT_ACCESS_TOKEN` | `GENERATE` (default) creates a random token, preserved on re-install; required for chat, status and data APIs | | `INFLUXDB_CONTAINER_NAME` | Advanced override for existing/custom Compose deployments | | `BOAT_TIMEZONE` | IANA timezone, such as `America/Los_Angeles` | @@ -709,8 +612,8 @@ nano vesselstack.env | `VESSELSTACK_DATA` | Persistent container-data path | | `VESSELSTACK_BACKUP` | Backup destination; must be outside the application and data trees | | `SIGNALK_MODE` | `existing`, `docker`, or `native` | -| `SIGNALK_VERSION` | Pinned SignalK release used by managed modes | -| `SIGNALK_URL` | Existing SignalK endpoint | +| `SIGNALK_VERSION` | Pinned Signal K release used by managed modes | +| `SIGNALK_URL` | Existing Signal K endpoint | | `SIGNALK_VESSELTWIN_PRELOAD` | Managed Docker/native modes preload immutable `signalk-vesseltwin` 0.2.3 (default `true`); existing mode ignores it, and `false` skips without uninstalling | | `HOME_ASSISTANT_TOKEN` | Optional long-lived token used by Boat Chat | | `SOCKETCAN_ENABLE` | Opt in only after the CAN interface is present | @@ -720,7 +623,7 @@ nano vesselstack.env | `AIS_DEVICE` | USB bus or receiver device mapped into AIS-catcher | | `AIS_CATCHER_ARGS` | Reviewed AIS-catcher receiver/output arguments | | `INFLUXDB_ORG` | InfluxDB organization | -| `INFLUXDB_RAW_BUCKET` | Full-resolution SignalK data; default `signalk` | +| `INFLUXDB_RAW_BUCKET` | Full-resolution Signal K data; default `signalk` | | `INFLUXDB_HISTORY_BUCKET` | One-minute retained history; default `signalk_1m` | | `INFLUXDB_HOME_ASSISTANT_BUCKET` | Home Assistant history bucket | | `INFLUXDB_AIS_BUCKET` | Short-retention AIS history bucket | @@ -735,8 +638,8 @@ nano vesselstack.env | `MQTT_USERNAME`, `MQTT_PASSWORD` | Mosquitto administrator account; the username may contain letters, numbers, `.`, `_` and `-` (1-64), and a custom password must not contain `:` or leading/trailing whitespace | | `DISPLAYS_ENABLE` | `false` by default; `true` adds the displays bridge and per-display MQTT accounts (see [MQTT access control](#mqtt-access-control)) | | `DISPLAYS_BRIDGE_MQTT_USERNAME` | Bridge account name, 1-64 letters, numbers, dot, underscore or hyphen; default `vesselstack-bridge`; must differ from `MQTT_USERNAME` (the Control Panel rejects a collision when displays are enabled, and the installer preflight rejects it) | -| `DISPLAYS_SIGNALK_TOKEN` | Optional SignalK access token for the displays bridge, sent as `Authorization: Bearer`; leave empty unless SignalK security requires it; must not contain line breaks or any other control character (the Control Panel and the installer both refuse them) | -| `DISPLAYS_HA_TOKEN` | Optional long-lived access token of a dedicated Home Assistant user (a leaked token can operate every device in that Home Assistant; see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)), used only by the displays bridge for Home Assistant items (`ha` in `displays.json`, see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)); the bridge connects to `HOME_ASSISTANT_URL` (default `http://127.0.0.1:8123`). Stored only in the root-only 0600 `config/displays-bridge.env`, never logged; same rule as the SignalK token: no line breaks or other control characters. Different from `HOME_ASSISTANT_TOKEN` (Boat Chat) | +| `DISPLAYS_SIGNALK_TOKEN` | Optional Signal K access token for the displays bridge, sent as `Authorization: Bearer`; leave empty unless Signal K security requires it; must not contain line breaks or any other control character (the Control Panel and the installer both refuse them) | +| `DISPLAYS_HA_TOKEN` | Optional long-lived access token of a dedicated Home Assistant user (a leaked token can operate every device in that Home Assistant; see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)), used only by the displays bridge for Home Assistant items (`ha` in `displays.json`, see [Home Assistant items](docs/DISPLAYS.md#home-assistant-items)); the bridge connects to `HOME_ASSISTANT_URL` (default `http://127.0.0.1:8123`). Stored only in the root-only 0600 `config/displays-bridge.env`, never logged; same rule as the Signal K token: no line breaks or other control characters. Different from `HOME_ASSISTANT_TOKEN` (Boat Chat) | | Terminal wizard and `--set` | The `DISPLAYS_*` settings are asked under "Advanced settings" (the bridge user name and the two tokens only when displays are enabled; the tokens are hidden and optional) and can be set headless, for example `--set DISPLAYS_ENABLE=true`; they are kept when you re-run the wizard | Never commit `vesselstack.env`. Generated secrets are stored in mode-600 files @@ -760,7 +663,7 @@ locally loaded container images (see Offline install). Resolve every error before continuing. -For native SignalK, install Node.js 22 or newer and npm before preflight. For +For native Signal K, install Node.js 22 or newer and npm before preflight. For SocketCAN or AIS, connect the adapter first: preflight intentionally fails when an enabled device is not detected. @@ -860,7 +763,7 @@ sudo journalctl -u vesselstack-chat.service --since "15 minutes ago" --no-pager Open `http://HOST:8123` and finish onboarding. Configure a notification provider, MQTT at `127.0.0.1:1883`, and sensors appropriate to the boat's -SignalK paths. VesselStack installs reusable low-battery, bilge/high-water, and +Signal K paths. VesselStack installs reusable low-battery, bilge/high-water, and shore-power-loss blueprints under **Settings → Automations → Blueprints**. Create automations from them only after validating the selected entities, thresholds, durations, and notification actions. @@ -871,18 +774,18 @@ starter Lovelace view. Copy it into a dashboard only after replacing every `sensor.vesselstack_*` example with a real entity from the vessel. VesselStack does not silently enable safety alerts or assume thresholds. -### SignalK and InfluxDB +### Signal K and InfluxDB -With `SIGNALK_MODE=docker`, VesselStack starts the official pinned SignalK +With `SIGNALK_MODE=docker`, VesselStack starts the official pinned Signal K image and persists its settings in `/opt/vesselstack-data/signalk` (owned by uid/gid 1000, the image's `node` user, so the container can write it). With `SIGNALK_MODE=native`, it installs the pinned npm package under `/opt/vesselstack/signalk-server` and runs it as the locked VesselStack user. -`SIGNALK_MODE=existing` never modifies SignalK. The managed modes use the same +`SIGNALK_MODE=existing` never modifies Signal K. The managed modes use the same first-run admin onboarding at `http://HOST:3000`. Managed Docker and native installs preload the pinned, zero-runtime-dependency -`signalk-vesseltwin` 0.2.3 package before SignalK starts. The archive is +`signalk-vesseltwin` 0.2.3 package before Signal K starts. The archive is integrity-checked before publication; an existing plugin directory is preserved and never overwritten, so a boat that already has the plugin updates it from the Signal K App Store. When a re-run newly preloads the plugin into an @@ -895,12 +798,12 @@ explicit owner actions (enable the plugin in **Server > Plugin Config** after either a preload or an App Store install). When AIS is enabled, AIS-catcher exposes its viewer on port `8100` and an NMEA -TCP feed on port `5011`. Add a SignalK NMEA 0183 TCP client connection to +TCP feed on port `5011`. Add a Signal K NMEA 0183 TCP client connection to `127.0.0.1:5011`. When SocketCAN is enabled, VesselStack validates the named interface and installs a 250-kbit/s-by-default systemd unit; configure the -matching SignalK NMEA 2000 connection in SignalK's admin UI. +matching Signal K NMEA 2000 connection in Signal K's admin UI. -Configure a SignalK InfluxDB 2-compatible output plugin with values from +Configure a Signal K InfluxDB 2-compatible output plugin with values from `/opt/vesselstack/config/vesselstack.env`: - URL: `http://127.0.0.1:8086` @@ -948,10 +851,9 @@ volume again; new metrics stay in `/prometheus`). #### Raw data retention -The full-resolution bucket (`INFLUXDB_RAW_BUCKET`) used to keep data forever, -which eventually fills the boat's disk. New installs now create it with a 30-day -retention (`INFLUXDB_RAW_RETENTION`, default `30d`); the one-minute history -bucket keeps one year as before. **Upgrades never change an existing bucket +Keeping the full-resolution bucket (`INFLUXDB_RAW_BUCKET`) forever eventually fills +the boat's disk, so new installs create it with a 30-day retention +(`INFLUXDB_RAW_RETENTION`, default `30d`); the one-minute history bucket keeps one year. **Upgrades never change an existing bucket implicitly**, because shortening retention permanently deletes older raw data. To apply a window to an existing install, set `INFLUXDB_RAW_RETENTION` (for example `90d`, or `0` to keep forever) in `vesselstack.env` or the control panel @@ -978,13 +880,13 @@ the service first, then update `config/vesselstack.env`, then re-apply: `MQTT_PASSWORD` is editable: the Mosquitto password file is created only when missing and otherwise updated in place (other users are kept) when the -username/password changed since the last run, so re-running the installer no -longer resets MQTT credentials. Update Home Assistant's MQTT integration to match. +username/password changed since the last run, so re-running the installer +does not reset MQTT credentials. Update Home Assistant's MQTT integration to match. #### Virtual CAN for home-lab replay On a lab machine without a CAN adapter you can replay a recorded log into a -virtual CAN interface and let SignalK read it as if it were the boat bus. Set +virtual CAN interface and let Signal K read it as if it were the boat bus. Set ```bash SOCKETCAN_ENABLE="true" @@ -1004,7 +906,7 @@ sudo apt install can-utils canplayer -I /path/to/your-candump.log vcan0=can0 # add -l i to loop forever ``` -Point the SignalK NMEA 2000/SocketCAN connection at `vcan0`. Recorded logs +Point the Signal K NMEA 2000/SocketCAN connection at `vcan0`. Recorded logs contain vessel identifiers and positions: keep them outside the repository (the public-tree check rejects `.log` files) and never commit or share them. @@ -1019,15 +921,15 @@ sudo grep '^GRAFANA_ADMIN_PASSWORD=' \ The installer provisions `VesselStack InfluxDB`, `VesselStack Prometheus`, and the **VesselStack / VesselStack System Health** starter dashboard. Use that as -the operational baseline, then add vessel dashboards for the SignalK paths +the operational baseline, then add vessel dashboards for the Signal K paths available on the boat. Provisioned dashboards are editable in Grafana; keep reusable versions in the repository so reinstallations remain reproducible. ### Boat Chat -Open `http://HOST:8765` and enter the access token (`BOAT_CHAT_ACCESS_TOKEN` in +Open `http://HOST:8765` and enter the access code (`BOAT_CHAT_ACCESS_TOKEN` in `config/boat-chat.env`); the default `local` provider sends nothing to an external model. -Settings, the settings token, telemetry refresh, optional host metrics, timezone and units +Settings, the admin code, telemetry refresh, optional host metrics, timezone and units are in [Boat Chat setup](docs/BOAT_CHAT.md); providers are in [Boat Chat providers](boat-chat/README.md). ### Control Panel @@ -1039,7 +941,7 @@ changes only that bind. See [Control Panel](docs/CONTROL_PANEL.md) for the full ### Heimdall Open `http://HOST` and add trusted-network links to Home Assistant, Grafana, -SignalK, Boat Chat, InfluxDB, and Prometheus. +Signal K, Boat Chat, InfluxDB, and Prometheus. ## MQTT access control @@ -1076,20 +978,19 @@ excluded; application source, configuration, memory, and data remain included. The destination is resolved before writing and is rejected if it falls inside the application or data tree, including through a symlink. Destinations under `/media` or `/mnt` must be a mounted filesystem (`mountpoint -q`), so a missing -USB drive can no longer silently fill the SD card; pass +USB drive cannot silently fill the SD card; pass `vesselstackctl backup --allow-unmounted DIR` (or set `VESSELSTACK_BACKUP_ALLOW_UNMOUNTED=true`, which also applies to `vesselstackctl update`) to override. A warning is printed when the destination is on the same filesystem as the data. Besides the application and data trees the -archive now includes `/etc/systemd/system/vesselstack-*`, +archive includes `/etc/systemd/system/vesselstack-*`, `/usr/local/sbin/vesselstack{ctl,-firewall}` and `/etc/vesselstack/update.env` when they exist, so a restore across versions is complete. The whole stack, including Home Assistant, stays stopped for the copy. Home Assistant, Grafana, InfluxDB and Boat Chat all keep SQLite or write-ahead-log state in the data tree, and a file copy of a running database is not -crash-consistent, so partial stops were rejected in favour of a consistent -archive. +crash-consistent, so the archive is taken with the whole stack stopped. Restore only during a maintenance window. Verification rejects path traversal, absolute or escaping links, device nodes, FIFOs and any setuid, setgid or sticky @@ -1099,7 +1000,7 @@ member (a legitimate backup never contains one) before anything is extracted: sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes ``` -Restore no longer extracts over the live tree. It (1) verifies the archive, +Restore never extracts over the live tree. It (1) verifies the archive, (2) checks free space (staging holds a second full copy next to the live trees; the previous trees are kept too, so you need room for old + new), (3) extracts as root with the recorded numeric owners into a private `.vesselstack-restore.*` @@ -1141,20 +1042,68 @@ live database files is insufficient. ## Updating -Back up first, then update through the guided path: +This is the one place in the README that describes updating and rolling back. + +1. **Back up first** to a mounted external drive (see [Backups](#backups)): + + ```bash + sudo vesselstackctl backup + ``` + +2. **Re-run the pinned release installer.** On the Pi, over `ssh -t`: + + ```bash + curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash + ``` + + The installer finds the installed system, never overwrites it silently, and offers + `vesselstackctl update ` for the release it unpacked under + `/opt/vesselstack-release/`. After you confirm, `vesselstackctl update` runs the + release's `install.sh --dry-run` preflight, makes and verifies a full backup, then runs + `install.sh --start`. Answer **n** to leave the system untouched; the installer prints + the exact command to run later. Services restart briefly, so do not update while underway. + Plain `sudo vesselstackctl update` with no argument fails: the command needs the release + directory, for example `sudo vesselstackctl update /opt/vesselstack-release/0.6.0`. + Without internet, use `--offline --bundle ...` as described under + [Offline install](#offline-install-no-internet-on-the-boat). + +3. **Or use the Control Panel.** An authenticated session can check the public + `jclima/VesselStack` releases. Stable releases are shown by default; enable + **Include lab prereleases** to list prereleases as well, which you need for any release + GitHub publishes as a prerelease. The panel shows the installed and running version, the + release channel, the release notes and a link to the release. **Back up & install** + asks for confirmation, verifies the downloaded SHA-256 manifest, safely extracts the + release, runs preflight, then makes and verifies a backup on a mounted external drive + (`/media` or `/mnt`; the browser cannot change the destination). A failed or unmounted + backup stops the update. A detached updater restarts the Control Panel and verifies its + new version before the browser reconnects. The panel uses ordinary HTTP and serializes + its own update, configuration and component operations; do not run CLI maintenance + commands at the same time. Transient updater files under `/var/lib/vesselstack` are not + backed up; configuration and data are. + +| Installed version | Update to 0.6.0 | +|---|---| +| 0.5.7 | Control Panel (enable **Include lab prereleases** if 0.6.0 is published as a prerelease) or the pinned installer | +| 0.5.5, 0.5.6 | Same as 0.5.7: the browser updater was introduced in 0.5.5 | +| 0.5.4 or earlier, including 0.4.x | Run the pinned installer from the CLI once (these versions cannot update from the browser); read [Upgrading to 0.6.0](#upgrading-to-060) first | + +A checksum shows a download is intact; it is not signed provenance. 0.6.0 release status: recorded in the [CHANGELOG](CHANGELOG.md) at release time. + +**Rollback.** Downgrades are refused. If an update fails, the verified pre-update backup is +kept. Check it and restore it during a maintenance window: ```bash -sudo vesselstackctl backup -sudo vesselstackctl update +sudo vesselstackctl verify-backup /path/to/vesselstack-TIMESTAMP.tar.gz +sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes ``` -To update from a reviewed release tag with `install.sh --dry-run` first, and to roll back from the -verified pre-update backup, see [Updating and offline install](docs/UPDATING.md). +There is no automatic rollback. To update from a reviewed source checkout with +`install.sh --dry-run` first, see [Updating and offline install](docs/UPDATING.md). ### Offline install (no internet on the boat) Prepare a bundle on a connected machine, carry it to the boat, and install with -`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.5.7.tar.gz`. +`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.6.0.tar.gz`. The bundle must be root-owned and not group/world-writable. Steps: [Updating and offline install](docs/UPDATING.md#offline-install-no-internet-on-the-boat). ## Stopping or removing services @@ -1215,7 +1164,7 @@ curl -fsS http://127.0.0.1:3000/signalk sudo journalctl -u vesselstack-chat.service -n 100 --no-pager ``` -Confirm SignalK and InfluxDB values in +Confirm Signal K and InfluxDB values in `/opt/vesselstack/config/boat-chat.env`. ### Mosquitto restarts repeatedly @@ -1253,24 +1202,24 @@ If `reindex` itself fails, its message names the cause (missing Python environment: rerun the installer; state directory missing or a file owned by root: the `chown` command it prints). -### SignalK not running / EACCES on package.json +### Signal K not running / EACCES on package.json Symptom (`SIGNALK_MODE=docker`): the `vesselstack-signalk` container restarts or exits and `sudo docker logs vesselstack-signalk` shows `EACCES: permission denied, open '/home/node/.signalk/package.json'`. The -`signalk-server` image runs SignalK as its `node` user (uid/gid 1000, config +`signalk-server` image runs Signal K as its `node` user (uid/gid 1000, config directory `/home/node/.signalk`), but `/signalk` was created by Docker as `root:root`. No release before 0.4.3 created it; 0.4.3 and later create it as `1000:1000` mode 0750 before `compose up` and repair a root-owned one on -every installer run, so rerunning the installer (`sudo vesselstackctl update` or -the installer from the release) fixes it. `sudo vesselstackctl doctor` also +every installer run, so rerunning the installer from the release (see +[Updating](#updating)) fixes it. `sudo vesselstackctl doctor` also detects the condition and reports it. Start with the read-only doctor output -and inspect the exact configured SignalK data directory and ownership. The +and inspect the exact configured Signal K data directory and ownership. The installer repairs only the known state conservatively: an empty or root-owned directory with safe contents may be repaired, while symlinks, hard links, special files, or another owner are left untouched with instructions. Resolve the indicated cause, then rerun the supported installer repair. Do not -blanket-chown the SignalK tree or restart the container while ownership and +blanket-chown the Signal K tree or restart the container while ownership and symlink state are still unknown. ### Container mount permissions fail @@ -1308,7 +1257,44 @@ value in a later commit does not remove it from earlier commits or releases. The distribution test rejects populated identity fields and private key material. This is a safety net, not a substitute for reviewing staged changes. +## Moved sections + +Earlier versions of this README held these sections. Old links to them land here and point to the current location. + +- [Alarm summary and takeover](docs/DISPLAYS.md#alarm-summary-and-takeover) +- [Automatic telemetry refresh](docs/BOAT_CHAT.md#automatic-telemetry-refresh) +- [Board registry schema 2](docs/DISPLAYS.md#board-registry-schema-2) +- [Building and flashing on the Pi](docs/DISPLAYS.md#building-and-flashing-on-the-pi) +- [Choose your path](#where-to-go-next) +- [Configuration and validation](docs/DISPLAYS.md#configuration-and-validation) +- [Current development improvements](docs/DEVELOPMENT.md#current-development-improvements) +- [Displays bridge service](docs/DISPLAYS.md#displays-bridge-service) +- [Find your next answer](#where-to-go-next) +- [Firmware: generate, build and flash](docs/DISPLAYS.md#firmware-generate-build-and-flash) +- [Hardware bring-up checklist (Waveshare ESP32-S3-Touch-LCD-2.8C)](docs/DISPLAYS.md#hardware-bring-up-checklist-waveshare-esp32-s3-touch-lcd-28c) +- [Home Assistant items](docs/DISPLAYS.md#home-assistant-items) +- [Icons](docs/DISPLAYS.md#icons) +- [Managing displays](docs/DISPLAYS.md#managing-displays) +- [MQTT topics and freshness](docs/DISPLAYS.md#mqtt-topics-and-freshness) +- [Notifications](docs/DISPLAYS.md#notifications) +- [One-command application updates](docs/DEVELOPMENT.md#one-command-application-updates) +- [Optional host metrics](docs/BOAT_CHAT.md#optional-host-metrics) +- [Planned limitations](docs/DISPLAYS.md#planned-limitations) +- [Quick start for an internet-connected Pi](#quick-start-one-command-on-the-raspberry-pi) +- [Selective core component lifecycle](docs/DEVELOPMENT.md#selective-core-component-lifecycle) +- [Settings and configuration precedence](docs/BOAT_CHAT.md#settings-and-configuration-precedence) +- [SignalK and InfluxDB](#signal-k-and-influxdb) +- [SignalK not running / EACCES on package.json](#signal-k-not-running--eacces-on-packagejson) +- [Supported boards](docs/DISPLAYS.md#supported-boards) +- [The `electrical` layout](docs/DISPLAYS.md#the-electrical-layout) +- [The `status-grid` layout](docs/DISPLAYS.md#the-status-grid-layout) +- [The `tank-bars` and `gauge-arc` layouts](docs/DISPLAYS.md#the-tank-bars-and-gauge-arc-layouts) +- [Thresholds and severity colours](docs/DISPLAYS.md#thresholds-and-severity-colours) +- [Touch, header, night mode and dimming](docs/DISPLAYS.md#touch-header-night-mode-and-dimming) +- [Update from the Control Panel (0.5.7 lab release)](#updating) +- [Version 1.0 release scope](docs/DEVELOPMENT.md#version-10-release-scope-historical) + ## Development and release history -Historical release scope and the unreleased review-fix notes are kept in +Historical release scope and review-fix notes are kept in [Development: Historical notes](docs/DEVELOPMENT.md#historical-notes). Release notes are in `CHANGELOG.md`. diff --git a/SECURITY.md b/SECURITY.md index 813bc83..14a6037 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,10 @@ ## Supported versions -Security fixes are provided for the latest published release and the default -branch. Pre-release builds may change without compatibility guarantees. +Security fixes are provided for the newest published release (the highest version +number, whether or not GitHub marks it Latest or as a prerelease) and for the default +branch. Older releases, including 0.4.x, receive no fixes: update to the newest release. +Pre-release builds may change without compatibility guarantees. ## Reporting a vulnerability diff --git a/VALIDATION.md b/VALIDATION.md index ca7abad..cbe970a 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,10 +1,41 @@ -# Historical 0.3.0 validation (schema 1.3) +# Validation + +Current automated gates come first. The 0.3.0 record that follows is kept for history. + +## 0.6.0 automated gates + +GitHub Actions (`.github/workflows/ci.yml`) runs these on every pull request; the release +workflow reruns the whole CI workflow on the tagged commit. None of them replaces +commissioning on the boat. + +- README update required: a pull request that does not change `README.md` fails. +- Privacy check (`scripts/check-public-tree.py`) and static distribution checks + (`tests/test-distribution.sh`, including ShellCheck and that `DEFAULT_VERSION` equals `VERSION`). +- Lifecycle backup, verify and restore; versioned migrations; clean installation; offline and + transactional installation; the update launcher's root-trust checks; the guided + `get-vesselstack.sh` bootstrap; SocketCAN unit rendering; release package contents. +- A real systemd browser-update acceptance run, and the Signal K VesselTwin preload security tests as root. +- Docker Compose validation with the default and optional profiles, and a check that every + pinned image publishes Linux ARM64. +- All Python regressions (`python3 -m unittest discover -s tests -p 'test_*.py'`) and the + Control Panel UI regression checks (`node tests/test_control_panel_ui.cjs`). +- A release archive build with a SHA-256 check of the archive and of `get-vesselstack.sh`. +- Disposable InfluxDB, Prometheus and Grafana startup, the Mosquitto access list and display + accounts, the Mosquitto upgrade path, the Signal K to MQTT displays bridge, and its + EnvironmentFile under real systemd. +- The generated display projects are validated with the pinned ESPHome image. + +Not covered by CI: SocketCAN and NMEA 2000 wiring, SDR/AIS reception, real Raspberry Pi +hardware and upgrades, and vessel-specific alarm thresholds. 0.6.0 release status: recorded +in the CHANGELOG at release time. + +## Historical 0.3.0 validation (schema 1.3) This record describes the evidence used for the historical 0.3.0 release. It is not a current-release gate and does not convert VesselStack into certified marine safety equipment. -## Automated release gates +### Automated release gates (0.3.0) - Bash syntax and ShellCheck for installer, lifecycle, migration, hardware, firewall, packaging, and integration scripts. @@ -13,16 +44,16 @@ marine safety equipment. - Clean installer render into an isolated filesystem, including permissions, generated credentials, branding, systemd units, Grafana assets, Home Assistant assets, and installed schema version. -- Explicit 0.1/1.0/1.1/1.2-to-1.3 migration and rejection of unknown future schemas. +- Explicit 0.1/1.0/1.1/1.2-to-1.3 migration (schema labels of that era) and rejection of unknown future schemas. - Checksummed backup verification and actual restoration of changed state. -- Docker Compose rendering with default, SignalK, and AIS profiles. +- Docker Compose rendering with default, Signal K, and AIS profiles. - Registry-manifest verification that every pinned image publishes Linux ARM64, with bounded retries for transient registry transport failures. - Disposable InfluxDB, Prometheus, and Grafana startup; health queries; bucket creation; idempotent Flux task creation; datasource lookup; and dashboard lookup. - Boat Chat, Control Panel, query-planner, session-store, API-integration, and - wizard tests, including installed SignalK, Home Assistant, InfluxDB + wizard tests, including installed Signal K, Home Assistant, InfluxDB endpoint/credential use, request limits, token handling, configuration rollback, and operation allowlists. - Lifecycle tests for complete first start, per-service diagnostics, nested @@ -35,7 +66,7 @@ marine safety equipment. content and overlay checks, browser error inspection, and two reviewed documentation screenshots. -## Raspberry Pi validation host +### Raspberry Pi validation host (0.3.0) - Linux ARM64 host - Linux `aarch64`, 64-bit diff --git a/VERSION b/VERSION index d3532a1..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.7 +0.6.0 diff --git a/boat-chat/README.md b/boat-chat/README.md index edc9882..8f58996 100644 --- a/boat-chat/README.md +++ b/boat-chat/README.md @@ -4,9 +4,8 @@ First-run access, settings, telemetry refresh, units and timezone are in [Boat C Local read-only diagnostic chatbot for VesselStack. -This guide follows current `main`. The `0.5.7` lab prerelease includes -selectable unit presentation and separate telemetry sampling and indexing -timers. +This guide follows current `main`. Release 0.6.0 includes selectable unit +presentation and separate telemetry sampling and indexing timers. ## Run @@ -41,7 +40,7 @@ Without a configured model provider, the app still gathers and returns local con Boat Chat keeps context in tiers so common questions stay fast and small local models do not have to infer stable facts from long runbooks: - Tier 0: stable boat facts from `boat-chat/boat_facts.json` such as vessel name, model, MMSI, call sign, host, telemetry stack, and engine layout. -- Tier 1: live SignalK and Home Assistant state, fetched only when relevant. +- Tier 1: live Signal K and Home Assistant state, fetched only when relevant. - Tier 2: InfluxDB historical summaries, fetched only for questions that need history such as engine runtime or fuel usage. - Tier 3: local docs and runbooks from the SQLite memory index, limited to the top matches. @@ -53,7 +52,7 @@ Runtime behavior is governed by `boat-chat/BOAT_CHAT_AGENT.md`. It is optimized Complex questions use semantic signal groups instead of requiring every query word to appear in one measurement name. Boat Chat can gather multiple requested signals, exact local windows (including the recorded last trip), min/max timestamps, aligned values at notable times, port/starboard comparisons, RPM-matched comparisons, threshold durations, and correlations. Engine diagnostics include a separate engine-running view so normal engine-off zero readings do not become false low-pressure or fuel-flow findings. -Current SignalK values are converted to the configured `BOAT_UNITS` display units before model reasoning, and timestamps use the effective `BOAT_TIMEZONE` configured at service startup. Source instants, offsets, and unit labels remain authoritative; unknown units are not guessed. Nearby-vessel/AIS questions use the SignalK `/vessels` feed and include target distance, speed, course, position timestamp, and staleness. Home Assistant contributes current states, unavailable/stale review lists, and relevant retained state transitions for complex alert, shore-power, trip, bilge, and service questions. +Current Signal K values are converted to the configured `BOAT_UNITS` display units before model reasoning, and timestamps use the effective `BOAT_TIMEZONE` configured at service startup. Source instants, offsets, and unit labels remain authoritative; unknown units are not guessed. Nearby-vessel/AIS questions use the Signal K `/vessels` feed and include target distance, speed, course, position timestamp, and staleness. Home Assistant contributes current states, unavailable/stale review lists, and relevant retained state transitions for complex alert, shore-power, trip, bilge, and service questions. The web and Telegram clients send the last few conversation turns with each request. A short answer to a clarification, such as `last 30 days`, is combined with the prior user question instead of being treated as a new unrelated question. Telegram `/clear` and the web Clear button reset that context. @@ -80,7 +79,7 @@ Use the `Settings` button in the web app to configure: - Context budget, output budget, and CLI timeout. - Codex CLI, Claude CLI, Ollama, OpenAI, Vercel AI Gateway, AWS Bedrock, Google, and OpenAI-compatible provider settings. - Telegram bot token and allowed chat IDs. -- Server host/port and an optional settings write token. +- Server host/port and an optional admin code (`BOAT_CHAT_SETTINGS_TOKEN`) that protects settings changes. Base settings are stored in `/opt/vesselstack/config/boat-chat.env`; Settings-page changes are written to the service-user override file at `$VESSELSTACK_DATA/boat-chat/boat-chat.env`. Provider, model, token, and credential changes are read dynamically by the next chat request. Host/port changes require: @@ -90,7 +89,7 @@ sudo systemctl restart vesselstack-chat.service Secrets are not shown back in the browser; blank password fields keep the existing saved value. -Settings writes (`POST /api/settings`) require loopback when no settings token is configured. If `BOAT_CHAT_SETTINGS_TOKEN` is configured, the matching `X-Boat-Chat-Token` header is required from every address, including loopback and private LAN addresses. The web UI remembers the token only in browser session storage after you enter it in the Server section. Closing the tab clears it. Keep the app off the public internet. +Settings requests (`GET` and `POST /api/settings`) require the access code (`BOAT_CHAT_ACCESS_TOKEN`). `POST` also needs the admin code where one is configured: without `BOAT_CHAT_SETTINGS_TOKEN` it is accepted from loopback only, and with it the matching `X-Boat-Chat-Token` header is required from every address, including loopback and private LAN addresses. The access code normally travels in `X-Boat-Chat-Token`; when it differs from the admin code, send the access code in the optional `X-Boat-Chat-Access-Token` header and the admin code in `X-Boat-Chat-Token`; `GET`, `HEAD` and `POST` all accept that header. The access code cannot be cleared (blank or `null` returns 400 with "The access code cannot be cleared; set a new one instead.") unless `BOAT_CHAT_ALLOW_OPEN=true`. A save that blanks provider, model, max tokens, context characters, host or port, or gives an invalid number, is rejected with a 400. The web app calls these the **Access code** and **Admin code** and keeps them only in browser session storage; closing the tab clears them. Settings does not open until the current values have loaded, and it saves only the fields you changed. Keep the app off the public internet. ## Telemetry cache @@ -129,7 +128,7 @@ journalctl -u vesselstack-telemetry-indexer.service --since "1 hour ago" - `BOAT_CHAT_MAX_TOKENS` — optional, defaults to `1200`. - `BOAT_CHAT_CONTEXT_CHARS` — optional local-context budget for the prompt. Lower values such as `4000` keep simple small-model requests compact; complex historical questions automatically receive up to at least 18,000 characters so aligned evidence is not discarded. - `BOAT_CHAT_OLLAMA_NUM_CTX` — optional Ollama context window in tokens; defaults to `8192` so a larger context budget can never be silently truncated. Ollama calls also pass `keep_alive: -1` to keep the model resident in RAM (~2 GB for a 3B Q4 model), and the prompt places stable context before the question so Ollama can reuse its KV prefix cache — warm repeat calls drop from ~85 s to a few seconds. -- `BOAT_CHAT_SETTINGS_TOKEN` — optional write token for `POST /api/settings` (sent as `X-Boat-Chat-Token`). +- `BOAT_CHAT_SETTINGS_TOKEN` — optional admin code for `POST /api/settings` (sent as `X-Boat-Chat-Token`); the access code `BOAT_CHAT_ACCESS_TOKEN` is also required and, when different, is sent as `X-Boat-Chat-Access-Token`. - `BOAT_CHAT_CODEX_MODEL` — optional model for `codex_cli`; defaults to `gpt-5.4-mini` for lighter subscription usage. - `BOAT_CHAT_CODEX_EFFORT` — optional Codex reasoning effort; defaults to `low`. - `BOAT_CHAT_CODEX_BIN` — optional Codex binary path; defaults to `codex`. @@ -330,8 +329,8 @@ If `TELEGRAM_BOT_TOKEN` is unset, the worker idles and logs that it is waiting f ## Read-only data sources -- SignalK self telemetry: `http://127.0.0.1:3000/signalk/v1/api/vessels/self` -- SignalK nearby AIS traffic: `http://127.0.0.1:3000/signalk/v1/api/vessels` +- Signal K self telemetry: `http://127.0.0.1:3000/signalk/v1/api/vessels/self` +- Signal K nearby AIS traffic: `http://127.0.0.1:3000/signalk/v1/api/vessels` - Home Assistant: local REST API using the installed environment's configured bearer token (with `.mcp.json` retained as a compatibility fallback where supported). - InfluxDB: Flux queries using the installed environment's configured credentials (with `.mcp.json` retained as a compatibility fallback where supported). - Curated local docs: `boat-chat/BOAT_CHAT_AGENT.md`, `boat-chat/boat_facts.json`, `boat-chat/README.md`, and `boat-chat/knowledge/*.md`. diff --git a/boat-chat/app.py b/boat-chat/app.py index 53187de..a519179 100755 --- a/boat-chat/app.py +++ b/boat-chat/app.py @@ -513,13 +513,42 @@ def public_settings() -> dict[str, Any]: return payload -def update_settings(updates: dict[str, Any]) -> dict[str, Any]: - # Reject the whole request before writing anything: a newline/CR/NUL in a value - # would inject extra KEY=VALUE lines into the env file read by the services. +# Settings that must never be saved as a blank string: the server, chat budget and +# provider routing cannot run without a value. An explicit None is still an intentional +# "reset to the built-in default" and keeps its existing semantics. +REQUIRED_SETTING_KEYS = ( + "BOAT_CHAT_PROVIDER", + "BOAT_CHAT_MODEL", + "BOAT_CHAT_MAX_TOKENS", + "BOAT_CHAT_CONTEXT_CHARS", + "BOAT_CHAT_HOST", + "BOAT_CHAT_PORT", +) + + +def validate_settings_update(updates: dict[str, Any]) -> None: + """Reject the whole request before anything is written.""" for key, value in updates.items(): - if key in SETTING_KEYS and value is not None: - if any(char in str(value) for char in ("\n", "\r", "\x00")): - raise ValueError(f"{key} contains unsupported characters") + if key not in SETTING_KEYS: + continue + if value is not None and any(char in str(value) for char in ("\n", "\r", "\x00")): + # A newline/CR/NUL would inject extra KEY=VALUE lines into the env file. + raise ValueError(f"{key} contains unsupported characters") + if key == "BOAT_CHAT_ACCESS_TOKEN" and access_token_required() and (value is None or not str(value).strip()): + # Clearing the code would lock out every API client (the Settings page never sends blanks). + raise ValueError("The access code cannot be cleared; set a new one instead.") + if key in REQUIRED_SETTING_KEYS: + if value is not None and not str(value).strip(): + raise ValueError(f"{key} is required and cannot be blank") + for key in ("BOAT_CHAT_PORT", "BOAT_CHAT_MAX_TOKENS", "BOAT_CHAT_CONTEXT_CHARS"): + if key in updates and updates[key] is not None: + text = str(updates[key]).strip() + if not (text.isascii() and text.isdigit()) or int(text) < 1 or (key == "BOAT_CHAT_PORT" and int(text) > 65535): + raise ValueError(f"{key} must be a positive whole number" + (" up to 65535" if key == "BOAT_CHAT_PORT" else "")) + + +def update_settings(updates: dict[str, Any]) -> dict[str, Any]: + validate_settings_update(updates) with env_config.locked(ENV_CONFIG): current = parse_env_config() for key, value in updates.items(): @@ -5265,6 +5294,17 @@ def access_allowed(self) -> bool: provided = (self.headers.get("X-Boat-Chat-Token") or "").strip() return token_matches(provided, token) + def settings_access_header_allowed(self) -> bool: + """Access code sent in X-Boat-Chat-Access-Token, so one request can carry both + the access code and the admin code (X-Boat-Chat-Token) when they differ.""" + token = (setting_value("BOAT_CHAT_ACCESS_TOKEN") or "").strip() + provided = (self.headers.get("X-Boat-Chat-Access-Token") or "").strip() + return bool(token) and bool(provided) and token_matches(provided, token) + + def settings_read_allowed(self) -> bool: + """GET/HEAD /api/settings: the access code in either header, like POST.""" + return self.access_allowed() or self.settings_access_header_allowed() + def sensitive_access_allowed(self) -> bool: if not access_token_required(): return True @@ -5369,7 +5409,7 @@ def do_GET(self) -> None: ) return elif path == "/api/settings": - if not self.access_allowed(): + if not self.settings_read_allowed(): self.send_json(401, {"error": "invalid or missing X-Boat-Chat-Token header"}); return self.send_json(200, public_settings()) return @@ -5392,7 +5432,7 @@ def do_GET(self) -> None: def do_HEAD(self) -> None: path = urllib.parse.urlparse(self.path).path if path in {"/health", "/api/settings"}: - self.send_response(401 if path == "/api/settings" and not self.access_allowed() else 200) + self.send_response(401 if path == "/api/settings" and not self.settings_read_allowed() else 200) self.end_headers() return self.send_static(self.static_target(path), include_body=False) @@ -5414,6 +5454,10 @@ def do_POST(self) -> None: return if path == "/api/settings": try: + # Same access check as GET /api/settings, then the admin (write) check. + if not self.settings_read_allowed(): + self.send_json(401, {"error": "invalid or missing X-Boat-Chat-Token header"}) + return allowed, reason = self.settings_write_allowed() if not allowed: self.send_json(403, {"error": reason}) diff --git a/boat-chat/static/app.js b/boat-chat/static/app.js index afb9308..07babc5 100644 --- a/boat-chat/static/app.js +++ b/boat-chat/static/app.js @@ -5,9 +5,7 @@ if (new URLSearchParams(window.location.search).get("embedded") === "ha") { const form = document.querySelector("#chat-form"); const input = document.querySelector("#message"); const messages = document.querySelector("#messages"); -const context = document.querySelector("#context-json"); -const contextSummary = document.querySelector("#context-summary"); -const contextDrawer = document.querySelector(".context-drawer"); +const announcer = document.querySelector("#sr-announcer"); const status = document.querySelector("#status"); const routeStatus = document.querySelector("#route-status"); const settingsDialog = document.querySelector("#settings-dialog"); @@ -96,6 +94,27 @@ answerMode.value = localStorage.getItem("boatChatAnswerMode") || "concise"; answerMode.addEventListener("change",()=>localStorage.setItem("boatChatAnswerMode",answerMode.value)); let activeController = null; let progressTimer = null; +const CHAT_TIMEOUT_MS = 180000; + +function announce(text) { + // Separate polite live region: the conversation itself is not live, so restored + // history and the per-second counter are never read out. + announcer.textContent = ""; + window.setTimeout(() => { announcer.textContent = text; }, 60); +} +function accessCode() { try { return sessionStorage.getItem("boatChatAccessToken") || ""; } catch { return ""; } } +function describeHttpError(status, data) { + if (status === 401) return "Access code needed or not accepted. Choose Connect."; + if (status === 429) return "Too many requests. Wait a minute, then try again."; + return (data && typeof data.error === "string" && data.error) || `The boat returned an error (HTTP ${status}).`; +} +async function fetchJson(url, options = {}) { + const response = await fetch(url, options); + const data = await response.json().catch(() => null); + if (!response.ok) { const error = new Error(describeHttpError(response.status, data)); error.status = response.status; throw error; } + if (data === null) throw new Error("The boat returned a response that could not be read."); + return data; +} function appendInline(parent, text) { // Deliberately small text-only markup parser. Never interpret model HTML. @@ -160,6 +179,18 @@ function setStatus(text, state = "ready") { status.classList.toggle("is-busy", state === "busy"); status.classList.toggle("is-error", state === "error"); } +// The header chip reports the same sources as the Overview (/health plus the boat +// status request), so the two can never contradict each other. +let healthState = "unknown"; +function refreshConnectionStatus() { + if (activeController) return; + if (accessTokenRequired && !accessCode()) setStatus("Not connected", "error"); + else if (connectionState === "offline" || healthState === "offline") setStatus("Offline", "error"); + else if (healthState === "limited") setStatus("Limited data", "error"); + else if (connectionState === "connected" && lastBoatStatus) { + if (isStatusOutdated()) setStatus("Status outdated", "busy"); else setStatus("Connected"); + } else setStatus("Checking…", "busy"); +} function providerName(value) { return providerLabels[value] || value || "none"; @@ -175,35 +206,6 @@ function setRoute(provider, fallbackProvider, models) { routeStatus.title = route; } -function summarizeContext(payload) { - if (!payload || typeof payload !== "object" || !Object.keys(payload).length) { - return "No request yet"; - } - const keys = Object.keys(payload).filter((key) => payload[key] !== undefined && payload[key] !== null); - const useful = keys.filter((key) => !["context_profile", "question"].includes(key)); - const count = useful.length || keys.length; - return payload.evidence?.length ? `${payload.evidence.length} evidence item${payload.evidence.length === 1 ? "" : "s"}` : `${count} detail${count === 1 ? "" : "s"}`; -} - -function setContext(payload) { - context.replaceChildren(); - if (!payload) { - context.textContent = "No request yet."; - contextSummary.textContent = "No request yet"; - contextDrawer.hidden = true; - return; - } - contextDrawer.hidden = false; - const list = document.createElement("ul"); list.className = "evidence-list"; - (payload.evidence || []).forEach((item) => { - const row = document.createElement("li"); const label = document.createElement("strong"); const detail = document.createElement("span"); - label.textContent = item.label; detail.textContent = item.detail; row.append(label, detail); list.appendChild(row); - }); - if (!(payload.evidence || []).length) list.textContent = "No additional evidence details for this answer."; - context.appendChild(list); - contextSummary.textContent = summarizeContext(payload); -} - function setBusy(isBusy) { sendButton.disabled = isBusy; clearButton.disabled = isBusy; @@ -224,22 +226,38 @@ let lastStatusReceived = 0; let connectionState = "disconnected"; let accessTokenRequired = false; const connectDialog = document.querySelector("#connect-dialog"); -function showView(view) { document.body.dataset.view = view; } +function showView(view) { + document.body.dataset.view = view; + [["overview", "#overview-open"], ["chat", "#chat-open"]].forEach(([name, selector]) => { + const button = document.querySelector(selector); + button.setAttribute("aria-pressed", String(view === name)); + if (view === name) button.setAttribute("aria-current", "page"); else button.removeAttribute("aria-current"); + }); +} function updateAccessControls() { const connectButton = document.querySelector("#connect-open"); connectButton.hidden = !accessTokenRequired; if (!accessTokenRequired && connectDialog.open) connectDialog.close(); } +function statusSeconds() { + if (!lastBoatStatus) return null; + const age = lastBoatStatus.age_seconds; + const elapsed = Math.floor((Date.now() - lastStatusReceived) / 1000); + return age == null ? null : age + elapsed; +} +function isStatusOutdated() { + const seconds = statusSeconds(); + return seconds == null || seconds > 600 || connectionState !== "connected"; +} function ageText() { const output = document.querySelector("#observation-age"); if (!lastBoatStatus) return; - const age = lastBoatStatus.age_seconds; - const elapsed = Math.floor((Date.now() - lastStatusReceived) / 1000); - const seconds = age == null ? null : age + elapsed; - const outdated = seconds == null || seconds > 600 || connectionState !== "connected"; + const seconds = statusSeconds(); + const outdated = isStatusOutdated(); output.textContent = `${connectionState === "offline" ? "Connection lost — retained observations. " : ""}${seconds == null ? "Observation time unavailable" : `Snapshot collected ${Math.floor(seconds / 60)} min ago`}. ${outdated ? "Outdated or incomplete — refresh before relying on it." : "Individual sensors may report less often."}`; document.querySelector("#owner-overview").dataset.outdated = String(outdated); if (outdated) document.querySelector("#boat-condition").textContent = "Status needs refreshing"; + refreshConnectionStatus(); } function renderBoatStatus(data) { lastBoatStatus = data; lastStatusReceived = Date.now(); @@ -272,33 +290,35 @@ function clearDeviceStatus(message) { document.querySelector("#owner-attention").textContent="Boat status has not been checked."; document.querySelector("#owner-system").textContent="Connect to check system status."; insightsContent.replaceChildren(); document.querySelector("#connect-open").textContent="Connect"; updateAccessControls(); + refreshConnectionStatus(); } async function loadBoatStatus() { if(statusBusy) return; - const token=sessionStorage.getItem("boatChatAccessToken") || ""; + const token=accessCode(); if(accessTokenRequired && !token) { connectionState="disconnected"; clearDeviceStatus("Connect this device to view boat observations."); return; } statusBusy=true; try { const response=await fetch("/api/status",{headers:{"X-Boat-Chat-Token":token},signal:AbortSignal.timeout(10000)}); - if(response.status===401) { accessTokenRequired=true; updateAccessControls(); connectionState="disconnected"; clearDeviceStatus("Access token rejected. Choose Connect to sign in again."); return; } + if(response.status===401) { accessTokenRequired=true; updateAccessControls(); connectionState="disconnected"; clearDeviceStatus("Access code not accepted. Choose Connect to sign in again."); return; } if(!response.ok) throw new Error(`HTTP ${response.status}`); connectionState="connected"; renderBoatStatus(await response.json()); } catch(error) { connectionState="offline"; if(lastBoatStatus) ageText(); - else { clearDeviceStatus("Cannot reach the boat. Check your boat Wi-Fi or ZeroTier connection, then refresh."); document.querySelector("#boat-condition").textContent="Boat connection unavailable"; } - } finally { statusBusy=false; } + else { clearDeviceStatus("Cannot reach the boat. Check that you are on the boat Wi-Fi or connected through your remote-access VPN, then refresh."); document.querySelector("#boat-condition").textContent="Boat connection unavailable"; } + } finally { statusBusy=false; refreshConnectionStatus(); } } document.querySelector("#overview-open").addEventListener("click",()=>{showView("overview");loadBoatStatus();}); document.querySelector("#chat-open").addEventListener("click",()=>{showView("chat");input.focus();}); document.querySelector("#refresh-status").addEventListener("click",loadBoatStatus); -document.querySelector("#connect-open").addEventListener("click",()=>connectDialog.showModal()); +function openConnect() { connectDialog.showModal(); document.querySelector("#connect-token").focus(); } +document.querySelector("#connect-open").addEventListener("click",openConnect); document.querySelector("#connect-close").addEventListener("click",()=>connectDialog.close()); document.querySelector("#disconnect-device").addEventListener("click",()=>{sessionStorage.removeItem("boatChatAccessToken");connectionState="disconnected";clearDeviceStatus("Device disconnected.");connectDialog.close();}); document.querySelector("#connect-form").addEventListener("submit",async(event)=>{ event.preventDefault(); const field=document.querySelector("#connect-token"); const token=field.value.trim(); const message=document.querySelector("#connect-message"); message.textContent="Checking access…"; try { const response=await fetch("/api/status",{headers:{"X-Boat-Chat-Token":token},signal:AbortSignal.timeout(10000)}); - if(!response.ok) throw new Error(response.status===401 ? "Token not accepted. Check the Boat Chat access token and try again." : "Boat status is unavailable. Try again."); + if(!response.ok) throw new Error(response.status===401 ? "Access code not accepted. Check the Boat Chat access code and try again." : "Boat status is unavailable. Try again."); const data=await response.json(); sessionStorage.setItem("boatChatAccessToken",token); field.value=""; connectionState="connected";renderBoatStatus(data);message.textContent="";connectDialog.close(); } catch(error) { message.textContent=error.message; } }); @@ -316,9 +336,9 @@ function hubCard(title, wide = false) { } async function saveMaintenance(task) { - const token=sessionStorage.getItem("boatChatAccessToken") || ""; - const response=await fetch("/api/maintenance",{method:"POST",headers:{"Content-Type":"application/json","X-Boat-Chat-Token":token},body:JSON.stringify(task)}); - const data=await response.json(); if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); await loadInsights(); + const token=accessCode(); + await fetchJson("/api/maintenance",{method:"POST",headers:{"Content-Type":"application/json","X-Boat-Chat-Token":token},body:JSON.stringify(task)}); + await loadInsights(); } function alertSignature(data) { @@ -330,7 +350,7 @@ function processProactiveAlerts(data) { localStorage.setItem("boatChatAlertSignature",signature); if (previous !== null && signature && signature !== previous && localStorage.getItem("boatChatBrowserAlerts")==="on" && Notification.permission==="granted") { const warnings=(data.alerts || []).filter((item)=>item.severity!=="ok"); - new Notification("VesselStack boat alert",{body:warnings.map((item)=>`${item.label}: ${item.value}`).join(" · "),tag:"boat-health",renotify:true}); + new Notification("Boat Chat alert",{body:warnings.map((item)=>`${item.label}: ${item.value}`).join(" · "),tag:"boat-health",renotify:true}); } } @@ -346,35 +366,58 @@ function addAisRadar(card, ais) { if (!targets.length) { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent=ais?.error ? `AIS unavailable: ${ais.error}` : "No positioned AIS targets nearby."; card.appendChild(empty); return; } const range=Math.max(1,Math.min(10,Math.ceil(Math.max(...targets.map((item)=>Number(item.distance_nm)))))); const svg=document.createElementNS("http://www.w3.org/2000/svg","svg"); svg.classList.add("radar"); svg.setAttribute("viewBox","0 0 300 300"); svg.setAttribute("role","img"); svg.setAttribute("aria-label",`${targets.length} AIS targets within ${range} nautical miles`); - [50,100,140].forEach((radius)=>{const circle=document.createElementNS(svg.namespaceURI,"circle"); circle.setAttribute("cx","150"); circle.setAttribute("cy","150"); circle.setAttribute("r",String(radius)); circle.setAttribute("fill","none"); circle.setAttribute("stroke","#bdd0d2"); circle.setAttribute("stroke-width","1"); svg.appendChild(circle);}); - const own=document.createElementNS(svg.namespaceURI,"circle"); own.setAttribute("cx","150"); own.setAttribute("cy","150"); own.setAttribute("r","6"); own.setAttribute("fill","currentColor"); svg.appendChild(own); - targets.forEach((target)=>{const angle=(Number(target.bearing_deg)-90)*Math.PI/180; const radius=Math.min(136,(Number(target.distance_nm)/range)*136); const dot=document.createElementNS(svg.namespaceURI,"circle"); dot.setAttribute("cx",String(150+Math.cos(angle)*radius)); dot.setAttribute("cy",String(150+Math.sin(angle)*radius)); dot.setAttribute("r",target.position_stale ? "3" : "5"); dot.setAttribute("fill",target.position_stale ? "#d98a17" : "#176b70"); const title=document.createElementNS(svg.namespaceURI,"title"); title.textContent=`${target.name || "Unknown"}: ${target.distance_nm} nm`; dot.appendChild(title); svg.appendChild(dot);}); card.appendChild(svg); + [50,100,140].forEach((radius)=>{const circle=document.createElementNS(svg.namespaceURI,"circle"); circle.setAttribute("class","radar-ring"); circle.setAttribute("cx","150"); circle.setAttribute("cy","150"); circle.setAttribute("r",String(radius)); svg.appendChild(circle);}); + const own=document.createElementNS(svg.namespaceURI,"circle"); own.setAttribute("class","radar-own"); own.setAttribute("cx","150"); own.setAttribute("cy","150"); own.setAttribute("r","6"); svg.appendChild(own); + targets.forEach((target)=>{const angle=(Number(target.bearing_deg)-90)*Math.PI/180; const radius=Math.min(136,(Number(target.distance_nm)/range)*136); const dot=document.createElementNS(svg.namespaceURI,"circle"); dot.setAttribute("cx",String(150+Math.cos(angle)*radius)); dot.setAttribute("cy",String(150+Math.sin(angle)*radius)); dot.setAttribute("r",target.position_stale ? "3" : "5"); dot.setAttribute("class",target.position_stale ? "radar-target is-stale" : "radar-target"); const title=document.createElementNS(svg.namespaceURI,"title"); title.textContent=`${target.name || "Unknown"}: ${target.distance_nm} nm`; dot.appendChild(title); svg.appendChild(dot);}); card.appendChild(svg); const list=document.createElement("ol"); list.className="ais-list"; targets.slice(0,5).forEach((target)=>{const row=document.createElement("li"); row.textContent=`${target.name || "Unknown"} · ${target.distance_nm} nm · ${target.bearing_deg}°${target.position_stale ? " · stale" : ""}`; list.appendChild(row);}); card.appendChild(list); } function downloadTripReport(trip) { - const lines=["VesselStack trip report",`Window: ${trip.start_local} to ${trip.stop_local}`,`Duration: ${trip.duration_minutes ?? "unknown"} minutes`,"",trip.trip_summary || "No summary recorded."]; - const blob=new Blob([`${lines.join("\n")}\n`],{type:"text/plain"}); const url=URL.createObjectURL(blob); const link=document.createElement("a"); link.href=url; link.download=`theoffice-trip-${(trip.start_local || "report").slice(0,10)}.txt`; link.click(); URL.revokeObjectURL(url); + const lines=["Boat trip report",`Window: ${trip.start_local} to ${trip.stop_local}`,`Duration: ${trip.duration_minutes ?? "unknown"} minutes`,"",trip.trip_summary || "No summary recorded."]; + const blob=new Blob([`${lines.join("\n")}\n`],{type:"text/plain"}); const url=URL.createObjectURL(blob); const link=document.createElement("a"); link.href=url; link.download=`vesselstack-trip-${(trip.start_local || "report").slice(0,10)}.txt`; link.click(); URL.revokeObjectURL(url); } +function dueLabel(status) { return status === "overdue" ? "Overdue" : status === "soon" ? "Due soon" : ""; } + async function loadInsights() { - const token=sessionStorage.getItem("boatChatAccessToken") || ""; - insightsContent.textContent="Loading boat insights…"; + const token=accessCode(); + insightsContent.textContent="Loading Boat Hub…"; if (accessTokenRequired && !token) { insightsContent.textContent="Choose Connect to sign in to this device and view your trips and maintenance."; return; } try { - const response=await fetch("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); const data=await response.json(); - if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`); insightsContent.replaceChildren(); + const data=await fetchJson("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); insightsContent.replaceChildren(); const alerts=hubCard(`Boat · ${data.status.condition}`); const alertList=document.createElement("ul"); alertList.className="alert-list"; - data.alerts.forEach((item)=>{const row=document.createElement("li"); row.className="alert-row"; row.dataset.severity=item.severity; const label=document.createElement("span"); label.className="alert-label"; const dot=document.createElement("i"); dot.className="alert-dot"; const name=document.createElement("span"); name.textContent=item.label; label.append(dot,name); const value=document.createElement("strong"); value.textContent=item.value; row.append(label,value); alertList.appendChild(row);}); alerts.appendChild(alertList); const enableAlerts=document.createElement("button"); enableAlerts.type="button"; enableAlerts.className="alert-opt-in"; enableAlerts.textContent=localStorage.getItem("boatChatBrowserAlerts")==="on" ? "Browser alerts enabled" : "Enable browser alerts"; enableAlerts.addEventListener("click",()=>enableBrowserAlerts(enableAlerts)); alerts.appendChild(enableAlerts); processProactiveAlerts(data); + data.alerts.forEach((item)=>{const row=document.createElement("li"); row.className="alert-row"; row.dataset.severity=item.severity; const label=document.createElement("span"); label.className="alert-label"; const dot=document.createElement("i"); dot.className="alert-dot"; dot.setAttribute("aria-hidden","true"); const name=document.createElement("span"); name.textContent=item.label; label.append(dot,name); const value=document.createElement("strong"); const level=document.createElement("span"); level.className="sr-only"; level.textContent=`${item.severity}: `; value.append(level,document.createTextNode(item.value)); row.append(label,value); alertList.appendChild(row);}); alerts.appendChild(alertList); const enableAlerts=document.createElement("button"); enableAlerts.type="button"; enableAlerts.className="alert-opt-in"; enableAlerts.textContent=localStorage.getItem("boatChatBrowserAlerts")==="on" ? "Browser alerts enabled" : "Enable browser alerts"; enableAlerts.addEventListener("click",()=>enableBrowserAlerts(enableAlerts)); alerts.appendChild(enableAlerts); processProactiveAlerts(data); const trip=hubCard("Last recorded trip"); if (data.trip) { const summary=document.createElement("p"); summary.textContent=data.trip.trip_summary || `${data.trip.start_local} to ${data.trip.stop_local}`; const actions=document.createElement("div"); actions.className="trip-actions"; const askTrip=document.createElement("button"); askTrip.type="button"; askTrip.textContent="Analyze trip"; askTrip.addEventListener("click",()=>{insightsDialog.close();ask("Give me a detailed report for the last trip");}); const download=document.createElement("button"); download.type="button"; download.textContent="Download report"; download.addEventListener("click",()=>downloadTripReport(data.trip)); actions.append(askTrip,download); trip.append(summary,actions); } else { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent="No recorded trip window is available yet."; trip.appendChild(empty); } const position=hubCard("Vessel position"); - if (data.position) { const coords=document.createElement("strong"); coords.textContent=data.position.label; const note=document.createElement("p"); note.className="empty-state"; note.textContent="Kept local; no coordinates are sent to a map provider."; position.append(coords,note); } else { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent="Position is not present in the cached SignalK snapshot."; position.appendChild(empty); } + if (data.position) { const coords=document.createElement("strong"); coords.textContent=data.position.label; const note=document.createElement("p"); note.className="empty-state"; note.textContent="Kept local; no coordinates are sent to a map provider."; position.append(coords,note); } else { const empty=document.createElement("p"); empty.className="empty-state"; empty.textContent="Position is not present in the latest Signal K data."; position.appendChild(empty); } const ais=hubCard(`Nearby AIS · ${data.ais?.target_count || 0}`,true); addAisRadar(ais,data.ais); const maintenance=hubCard(`Maintenance · ${data.maintenance_counts?.open || 0} open${data.maintenance_counts?.overdue ? ` · ${data.maintenance_counts.overdue} overdue` : ""}`,true); const list=document.createElement("ul"); list.className="maintenance-list"; + const maintenanceError=document.createElement("p"); maintenanceError.className="form-error"; maintenanceError.setAttribute("role","alert"); maintenanceError.hidden=true; + const showMaintenanceError=(error)=>{ maintenanceError.textContent=`Could not save: ${error.message}`; maintenanceError.hidden=false; }; if (!data.maintenance.length) { const empty=document.createElement("li"); empty.className="empty-state"; empty.textContent="No maintenance tasks yet."; list.appendChild(empty); } - data.maintenance.forEach((task)=>{const row=document.createElement("li"); row.className="maintenance-row"; row.dataset.due=task.due_status; const check=document.createElement("input"); check.type="checkbox"; check.checked=task.completed; check.addEventListener("change",()=>saveMaintenance({...task,completed:check.checked})); const copy=document.createElement("span"); copy.className="maintenance-copy"; const titleText=document.createElement("strong"); titleText.textContent=`${task.title}${task.due_date ? ` · due ${task.due_date}` : ""}`; copy.appendChild(titleText); if(task.notes){const note=document.createElement("small"); note.textContent=task.notes; copy.appendChild(note);} const edit=document.createElement("button"); edit.type="button"; edit.textContent="Notes"; edit.addEventListener("click",()=>{const notes=window.prompt("Maintenance notes",task.notes || ""); if(notes!==null) saveMaintenance({...task,notes});}); row.append(check,copy,edit); list.appendChild(row);}); - const addForm=document.createElement("form"); addForm.className="maintenance-form"; const title=document.createElement("input"); title.required=true; title.placeholder="New maintenance task"; const due=document.createElement("input"); due.type="date"; const add=document.createElement("button"); add.type="submit"; add.textContent="Add task"; addForm.append(title,due,add); addForm.addEventListener("submit",async(event)=>{event.preventDefault(); add.disabled=true; try { await saveMaintenance({title:title.value,due_date:due.value}); } catch(error) { window.alert(error.message); add.disabled=false; }}); maintenance.append(list,addForm); + data.maintenance.forEach((task)=>{ + const row=document.createElement("li"); row.className="maintenance-row"; row.dataset.due=task.due_status; + const check=document.createElement("input"); check.type="checkbox"; check.checked=task.completed; check.setAttribute("aria-label",`Mark done: ${task.title}`); + check.addEventListener("change",async()=>{ maintenanceError.hidden=true; check.disabled=true; try { await saveMaintenance({...task,completed:check.checked}); } catch(error) { check.checked=!check.checked; check.disabled=false; showMaintenanceError(error); } }); + const copy=document.createElement("span"); copy.className="maintenance-copy"; const titleText=document.createElement("strong"); titleText.textContent=`${task.title}${task.due_date ? ` · due ${task.due_date}` : ""}`; + if (dueLabel(task.due_status)) { const badge=document.createElement("span"); badge.className="due-badge"; badge.textContent=dueLabel(task.due_status); titleText.appendChild(badge); } + copy.appendChild(titleText); if(task.notes){const note=document.createElement("small"); note.textContent=task.notes; copy.appendChild(note);} + const edit=document.createElement("button"); edit.type="button"; edit.textContent="Notes"; edit.setAttribute("aria-label",`Edit notes: ${task.title}`); + edit.addEventListener("click",()=>{ + if (copy.querySelector(".inline-form")) return; + const noteForm=document.createElement("form"); noteForm.className="inline-form"; + const noteInput=document.createElement("input"); noteInput.value=task.notes || ""; noteInput.setAttribute("aria-label",`Maintenance notes for ${task.title}`); + const saveNote=document.createElement("button"); saveNote.type="submit"; saveNote.textContent="Save notes"; + const cancelNote=document.createElement("button"); cancelNote.type="button"; cancelNote.textContent="Cancel"; cancelNote.addEventListener("click",()=>noteForm.remove()); + noteForm.addEventListener("submit",async(event)=>{ event.preventDefault(); maintenanceError.hidden=true; saveNote.disabled=true; try { await saveMaintenance({...task,notes:noteInput.value}); } catch(error) { saveNote.disabled=false; showMaintenanceError(error); } }); + noteForm.append(noteInput,saveNote,cancelNote); copy.appendChild(noteForm); noteInput.focus(); + }); + row.append(check,copy,edit); list.appendChild(row); + }); + const addForm=document.createElement("form"); addForm.className="maintenance-form"; const title=document.createElement("input"); title.required=true; title.placeholder="New maintenance task"; title.setAttribute("aria-label","New maintenance task"); const due=document.createElement("input"); due.type="date"; due.setAttribute("aria-label","Due date (optional)"); const add=document.createElement("button"); add.type="submit"; add.textContent="Add task"; addForm.append(title,due,add); + addForm.addEventListener("submit",async(event)=>{event.preventDefault(); maintenanceError.hidden=true; add.disabled=true; try { await saveMaintenance({title:title.value,due_date:due.value}); } catch(error) { showMaintenanceError(error); add.disabled=false; }}); + maintenance.append(list,addForm,maintenanceError); insightsContent.append(alerts,trip,position,ais,maintenance); } catch(error) { insightsContent.textContent=`Boat Hub unavailable: ${error.message}`; } } @@ -383,28 +426,39 @@ function addFeedbackControls(messageElement, requestId, question, feedbackSessio if (!requestId) return; const controls = document.createElement("div"); controls.className = "feedback-controls"; + const noteForm = document.createElement("form"); noteForm.className = "inline-form"; noteForm.hidden = true; + const noteInput = document.createElement("input"); noteInput.placeholder = "What could be improved? (optional)"; noteInput.setAttribute("aria-label", "What could be improved? (optional)"); + const noteSend = document.createElement("button"); noteSend.type = "submit"; noteSend.textContent = "Send feedback"; + const noteCancel = document.createElement("button"); noteCancel.type = "button"; noteCancel.textContent = "Cancel"; + const noteError = document.createElement("p"); noteError.className = "form-error"; noteError.setAttribute("role", "alert"); noteError.hidden = true; + noteForm.append(noteInput, noteSend, noteCancel); + let pendingRating = ""; + async function sendFeedback(rating, note, button) { + const token = accessCode(); + const headers = {"Content-Type":"application/json"}; + if (token) headers["X-Boat-Chat-Token"] = token; + button.disabled = true; noteError.hidden = true; + try { + const response = await fetch("/api/feedback", {method:"POST", headers, body:JSON.stringify({request_id:requestId, session_id:feedbackSessionId, rating, note})}); + if (!response.ok) throw new Error("Feedback could not be saved"); + controls.textContent = "Feedback saved"; noteForm.remove(); noteError.remove(); + } catch { + button.disabled = false; noteError.textContent = "Feedback could not be saved. Try again."; noteError.hidden = false; + } + } + noteForm.addEventListener("submit", (event) => { event.preventDefault(); sendFeedback(pendingRating, noteInput.value.trim(), noteSend); }); + noteCancel.addEventListener("click", () => { noteForm.hidden = true; noteError.hidden = true; }); [["Helpful", "helpful"], ["Incomplete", "incomplete"], ["Wrong", "wrong"]].forEach(([label, rating]) => { const button = document.createElement("button"); button.type = "button"; button.textContent = label; - button.addEventListener("click", async () => { - const token = sessionStorage.getItem("boatChatAccessToken") || ""; - const headers = {"Content-Type":"application/json"}; - if (token) headers["X-Boat-Chat-Token"] = token; - const note = rating === "helpful" ? "" : (window.prompt("What could be improved? (optional)", "") || ""); - button.disabled = true; - try { - const response = await fetch("/api/feedback", {method:"POST", headers, body:JSON.stringify({request_id:requestId, session_id:feedbackSessionId, rating, note})}); - if (!response.ok) throw new Error("Feedback could not be saved"); - controls.textContent = "Feedback saved"; - } catch { - button.disabled = false; - button.textContent = "Retry feedback"; - } + button.addEventListener("click", () => { + if (rating === "helpful") { sendFeedback(rating, "", button); return; } + pendingRating = rating; noteForm.hidden = false; noteInput.focus(); }); controls.appendChild(button); }); const save=document.createElement("button"); save.type="button"; save.textContent="Save question"; save.addEventListener("click",()=>{saveQuestion(question); save.textContent="Saved";}); controls.appendChild(save); - messageElement.appendChild(controls); + messageElement.append(controls, noteForm, noteError); } function addMetricCards(messageElement, metrics) { @@ -441,45 +495,58 @@ async function ask(message) { addMessage("user", message); const pending = addMessage("assistant", "Waiting for your boat's answer…"); pending.classList.add("is-pending"); + const counter = document.createElement("span"); counter.className = "count-hint"; counter.setAttribute("aria-hidden", "true"); + (pending.querySelector("p") || pending).appendChild(counter); setStatus("Working", "busy"); setBusy(true); + messages.setAttribute("aria-busy", "true"); const controller = new AbortController(); activeController = controller; + let timedOut = false; let statusIsError = false; + const timeoutId = setTimeout(() => { timedOut = true; controller.abort(); }, CHAT_TIMEOUT_MS); const began = Date.now(); - progressTimer = setInterval(() => { pending.textContent = `Waiting for your boat's answer… ${Math.floor((Date.now() - began) / 1000)}s`; }, 1000); + progressTimer = setInterval(() => { counter.textContent = ` ${Math.floor((Date.now() - began) / 1000)}s`; }, 1000); try { - const token = sessionStorage.getItem("boatChatAccessToken") || ""; + const token = accessCode(); const headers = {"Content-Type": "application/json"}; if (token) headers["X-Boat-Chat-Token"] = token; const response = await fetch("/api/chat", {method:"POST", headers, signal:controller.signal, body:JSON.stringify({message, history, session_id:requestSession, answer_mode:answerMode.value})}); - const data = await response.json().catch(() => ({})); - if (!response.ok) { const error = new Error(data.error || `HTTP ${response.status}`); error.status = response.status; throw error; } - if (typeof data.answer !== "string" || !data.answer.trim()) throw new Error("The boat returned an empty answer."); + const data = await response.json().catch(() => null); + if (!response.ok) { const error = new Error((data && data.error) || `HTTP ${response.status}`); error.status = response.status; throw error; } + if (!data || typeof data.answer !== "string" || !data.answer.trim()) throw new Error("The boat returned an empty answer."); clearInterval(progressTimer); pending.classList.remove("is-pending"); pending.replaceChildren(); renderAnswer(pending, data.answer); const turn = {role:"assistant", content:data.answer, experience:data.experience || {}, request_id:data.request_id, answered_at:new Date().toISOString(), session_id:requestSession}; decorateAnswer(pending, turn, message); conversation.push({role:"user", content:message}, turn); conversation = conversation.slice(-8); - setContext(null); - setStatus(persistConversation() ? "Answer ready" : "Answer ready · history not saved"); + const saved = persistConversation(); + announce(saved ? "Answer ready" : "Answer ready. History could not be saved in this browser."); messages.scrollTop = messages.scrollHeight; } catch (error) { clearInterval(progressTimer); pending.classList.remove("is-pending"); pending.replaceChildren(); - const cancelled = error.name === "AbortError"; - const explanation = cancelled ? "Stopped waiting. The boat may still finish processing this question." : - error.status === 401 ? "Connect this device to ask questions. Your question is kept below." : - error.status === 429 ? "Boat Chat is busy. Wait a moment, then retry." : "Couldn't get an answer. Check your connection and try again."; + const cancelled = error.name === "AbortError" && !timedOut; + const explanation = timedOut ? "The boat did not answer within 3 minutes. Your question is back in the message box; try again or ask something simpler." : + cancelled ? "Stopped waiting. The boat may still finish processing this question. Your question is back in the message box." : + error.status === 401 ? "Connect this device to ask questions. Your question is back in the message box." : + error.status === 429 ? "Boat Chat is busy. Wait a moment, then try again. Your question is back in the message box." : + "Couldn't get an answer. Check your connection and try again. Your question is back in the message box."; renderAnswer(pending, explanation); + announce(explanation); + // Put the question back so nothing typed is lost (unless the user already started a new one). + if (!input.value.trim()) { input.value = message; resizeInput(); } // A cancelled/failed request can still finish on the server. Isolate its session // so that an unseen answer cannot contaminate the next conversational turn. sessionId = createSessionId(); sessionStorage.setItem("boatChatSessionId", sessionId); const actions = document.createElement("div"); actions.className = "followups"; - if(error.status === 401) { const connect=document.createElement("button"); connect.type="button"; connect.textContent="Connect"; connect.addEventListener("click",()=>connectDialog.showModal()); actions.appendChild(connect); } - const retry=document.createElement("button"); retry.type="button"; retry.textContent="Retry question"; retry.addEventListener("click",()=>ask(message)); - const edit=document.createElement("button"); edit.type="button"; edit.textContent="Edit question"; edit.addEventListener("click",()=>{input.value=message;resizeInput();input.focus();}); actions.append(retry,edit); pending.appendChild(actions); - setStatus(cancelled ? "Stopped waiting" : error.status === 401 ? "Sign-in needed" : "Try again", cancelled ? "ready" : "error"); + if(error.status === 401) { const connect=document.createElement("button"); connect.type="button"; connect.textContent="Connect"; connect.addEventListener("click",openConnect); actions.appendChild(connect); } + const retry=document.createElement("button"); retry.type="button"; retry.textContent="Retry question"; retry.addEventListener("click",()=>{ if (input.value.trim() === message) { input.value = ""; resizeInput(); } ask(message); }); + actions.appendChild(retry); pending.appendChild(actions); + statusIsError = !cancelled; + setStatus(timedOut ? "Timed out" : cancelled ? "Stopped waiting" : error.status === 401 ? "Sign-in needed" : "Try again", cancelled ? "ready" : "error"); } finally { - clearInterval(progressTimer); progressTimer = null; activeController = null; setBusy(false); + clearTimeout(timeoutId); clearInterval(progressTimer); progressTimer = null; activeController = null; setBusy(false); + messages.removeAttribute("aria-busy"); + if (!statusIsError) refreshConnectionStatus(); } } @@ -488,15 +555,17 @@ cancelButton.addEventListener("click", () => activeController?.abort()); async function loadHealth() { try { const response = await fetch("/health"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); accessTokenRequired = data.access_token_required === true; updateAccessControls(); - setStatus(data.ready ? "Connected" : "Limited data", data.ready ? "ready" : "error"); + healthState = data.ready ? "ready" : "limited"; setRoute(data.provider || "local", data.fallback_provider || "", data.models || {}); } catch (error) { - setStatus("Offline", "error"); + healthState = "offline"; setRoute("local", "", {}); } + refreshConnectionStatus(); } function setSettingValue(name, value) { @@ -605,7 +674,7 @@ function updateProviderSettingsState() { const providers = String(section.dataset.providerConfig || "") .split(/\s+/) .filter(Boolean); - section.classList.toggle("is-relevant", providers.includes(primary) || providers.includes(fallback)); + section.hidden = !(providers.includes(primary) || providers.includes(fallback)); }); } @@ -620,15 +689,36 @@ async function loadModelCatalog() { } } +let settingsLoaded = false; +let initialSettings = {}; +const REQUIRED_SETTING_LABELS = { + BOAT_CHAT_PROVIDER: "Primary provider", + BOAT_CHAT_MAX_TOKENS: "Max tokens", + BOAT_CHAT_CONTEXT_CHARS: "Context chars", + BOAT_CHAT_HOST: "Host", + BOAT_CHAT_PORT: "Port", +}; + +function currentSettingValues() { + const values = {}; + for (const [key, value] of new FormData(settingsForm).entries()) values[key] = String(value).trim(); + values.__model = selectedModel("primary"); + values.__fallbackModel = selectedModel("fallback"); + return values; +} + async function loadSettings() { - const accessToken = sessionStorage.getItem("boatChatAccessToken") || ""; + settingsLoaded = false; + const accessToken = accessCode(); const response = await fetch("/api/settings", { headers: accessToken ? { "X-Boat-Chat-Token": accessToken } : {} }); - const data = await response.json(); + const data = await response.json().catch(() => null); if (response.status === 401) { - throw new Error("Connect this device (access token) before opening settings."); + const error = new Error("Connect this device with your access code before opening Settings."); + error.status = 401; + throw error; } - if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`); + if (!response.ok || !data || typeof data !== "object") { + throw new Error((data && data.error) || `The boat returned an error (HTTP ${response.status}).`); } loadedSettings = data.settings || {}; const currentSettingsToken = document.querySelector("#setting-current-token"); @@ -670,6 +760,9 @@ async function loadSettings() { const key = item.dataset.secret; item.textContent = data.secrets?.[key] ? "saved" : ""; }); + document.querySelectorAll("#settings-form input[type=password]").forEach((field) => { if (field.name) field.value = ""; }); + initialSettings = currentSettingValues(); + settingsLoaded = true; } function settingsToken() { @@ -678,32 +771,71 @@ function settingsToken() { return typed || sessionStorage.getItem("boatChatSettingsToken") || ""; } -async function saveSettings(event) { - event.preventDefault(); - settingsMessage.textContent = "Saving..."; - const formData = new FormData(settingsForm); - const settings = {}; - for (const [key, value] of formData.entries()) { - const text = String(value).trim(); - if (secretKeys.has(key) && !text) continue; - settings[key] = text; +function showSettingsError(message, needsConnect) { + settingsForm.dataset.state = "error"; + document.querySelector("#settings-error").hidden = false; + document.querySelector("#settings-error-text").textContent = message; + document.querySelector("#settings-connect").hidden = !needsConnect; + settingsMessage.textContent = ""; +} +function showSettingsReady() { + settingsForm.dataset.state = "ok"; + document.querySelector("#settings-error").hidden = true; +} +async function openSettings() { + settingsMessage.textContent = ""; + try { + await loadModelCatalog(); + await loadSettings(); + showSettingsReady(); + } catch (error) { + settingsLoaded = false; + showSettingsError(error.status === 401 ? error.message : `Settings could not be loaded: ${error.message}`, error.status === 401); } + if (!settingsDialog.open) settingsDialog.showModal(); +} - const primaryProvider = settings.BOAT_CHAT_PROVIDER || ""; - const primaryModel = selectedModel("primary"); - const primaryKey = primaryModelKey(primaryProvider); - settings[primaryKey] = primaryModel; - if (primaryKey !== "BOAT_CHAT_MODEL") { +// Only fields the user actually changed are sent, so an untouched or partly loaded form +// can never overwrite saved settings with blanks. +function changedSettings() { + const now = currentSettingValues(); + const settings = {}; + for (const [key, value] of Object.entries(now)) { + if (key.startsWith("__")) continue; + if (secretKeys.has(key)) { if (value) settings[key] = value; continue; } + if (value === (initialSettings[key] ?? "")) continue; + if (value === "" && REQUIRED_SETTING_LABELS[key]) throw new Error(`${REQUIRED_SETTING_LABELS[key]} cannot be blank.`); + settings[key] = value === "" ? null : value; + } + const provider = now.BOAT_CHAT_PROVIDER || ""; + const primaryKey = primaryModelKey(provider); + if (now.__model !== initialSettings.__model || provider !== (initialSettings.BOAT_CHAT_PROVIDER ?? "")) { + settings[primaryKey] = now.__model || null; // Clear the generic key so a stale value can't shadow the CLI model. - settings.BOAT_CHAT_MODEL = ""; + if (primaryKey !== "BOAT_CHAT_MODEL" && loadedSettings.BOAT_CHAT_MODEL) settings.BOAT_CHAT_MODEL = null; + } + const fallbackProvider = now.BOAT_CHAT_FALLBACK_PROVIDER || ""; + if (now.__fallbackModel !== initialSettings.__fallbackModel || fallbackProvider !== (initialSettings.BOAT_CHAT_FALLBACK_PROVIDER ?? "")) { + settings.BOAT_CHAT_FALLBACK_MODEL = fallbackProvider ? (now.__fallbackModel || null) : null; } - settings.BOAT_CHAT_FALLBACK_MODEL = settings.BOAT_CHAT_FALLBACK_PROVIDER ? selectedModel("fallback") : ""; + return settings; +} + +async function saveSettings(event) { + event.preventDefault(); + if (!settingsLoaded) { settingsMessage.textContent = "Settings are not loaded. Close this window and open Settings again."; return; } + let settings; + try { settings = changedSettings(); } catch (error) { settingsMessage.textContent = error.message; return; } + if (!Object.keys(settings).length) { settingsMessage.textContent = "No changes to save."; return; } + settingsMessage.textContent = "Saving..."; const token = settingsToken(); + const accessToken = accessCode(); const headers = { "Content-Type": "application/json" }; - if (token) { - headers["X-Boat-Chat-Token"] = token; - } + // X-Boat-Chat-Token carries the admin code; the access code travels separately so one + // request can prove both when they differ. + if (token || accessToken) headers["X-Boat-Chat-Token"] = token || accessToken; + if (accessToken) headers["X-Boat-Chat-Access-Token"] = accessToken; try { const response = await fetch("/api/settings", { @@ -711,9 +843,11 @@ async function saveSettings(event) { headers, body: JSON.stringify({ settings }), }); - const data = await response.json(); + const data = await response.json().catch(() => null); if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`); + if (response.status === 401) throw new Error("Access code needed or not accepted. Close Settings and choose Connect."); + if (response.status === 403) throw new Error("Settings can only be changed from this device, or with the correct admin code."); + throw new Error((data && data.error) || `HTTP ${response.status}`); } if (settings.BOAT_CHAT_SETTINGS_TOKEN || token) { sessionStorage.setItem("boatChatSettingsToken", settings.BOAT_CHAT_SETTINGS_TOKEN || token); @@ -750,6 +884,8 @@ input.addEventListener("focus", () => { }); input.addEventListener("keydown", (event) => { + // Enter confirms an IME composition (e.g. Japanese, Chinese); it must not send. + if (event.isComposing || event.keyCode === 229) return; if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); const message = input.value.trim(); @@ -767,43 +903,51 @@ document.querySelectorAll("[data-prompt]").forEach((button) => { }); }); +let clearArmTimer = null; +function disarmClear() { + clearTimeout(clearArmTimer); clearArmTimer = null; + clearButton.textContent = "Clear"; + clearButton.removeAttribute("aria-label"); +} clearButton.addEventListener("click", () => { + if (conversation.length && !clearArmTimer) { + // Two-step confirmation: the second press within 5 seconds clears. + clearButton.textContent = "Confirm clear"; + clearButton.setAttribute("aria-label", "Confirm: clear this conversation"); + announce("Press Clear again to confirm clearing this conversation."); + clearArmTimer = setTimeout(disarmClear, 5000); + return; + } + disarmClear(); const clearedSession = sessionId; sessionId = createSessionId(); sessionStorage.setItem("boatChatSessionId", sessionId); conversation = []; sessionStorage.removeItem("boatChatConversation"); - const token = sessionStorage.getItem("boatChatAccessToken") || ""; + const token = accessCode(); const headers = {"Content-Type":"application/json"}; if (token) headers["X-Boat-Chat-Token"] = token; fetch("/api/session/clear", {method:"POST", headers, body:JSON.stringify({session_id:clearedSession})}).catch(() => {}); messages.replaceChildren(); - setContext(null); addWelcomeMessage(); + announce("Conversation cleared"); input.focus(); }); document.querySelector("#capabilities-open").addEventListener("click", async () => { - capabilitiesContent.textContent = "Loading capabilities…"; capabilitiesDialog.showModal(); - try { const data=await (await fetch("/api/capabilities")).json(); capabilitiesContent.replaceChildren(); data.groups.forEach((group)=>{ const section=document.createElement("section"); section.className="capability-group"; const title=document.createElement("h2"); title.textContent=group.name; const prompts=document.createElement("div"); prompts.className="capability-prompts"; group.prompts.forEach((prompt)=>{ const button=document.createElement("button"); button.type="button"; button.textContent=prompt; button.addEventListener("click",()=>{capabilitiesDialog.close(); ask(prompt);}); prompts.appendChild(button); }); section.append(title,prompts); capabilitiesContent.appendChild(section); }); } catch(error) { capabilitiesContent.textContent="Capabilities are unavailable."; } + capabilitiesContent.textContent = "Loading the list of things you can ask…"; capabilitiesDialog.showModal(); + try { const data=await fetchJson("/api/capabilities"); capabilitiesContent.replaceChildren(); data.groups.forEach((group)=>{ const section=document.createElement("section"); section.className="capability-group"; const title=document.createElement("h2"); title.textContent=group.name; const prompts=document.createElement("div"); prompts.className="capability-prompts"; group.prompts.forEach((prompt)=>{ const button=document.createElement("button"); button.type="button"; button.textContent=prompt; button.addEventListener("click",()=>{capabilitiesDialog.close(); ask(prompt);}); prompts.appendChild(button); }); section.append(title,prompts); capabilitiesContent.appendChild(section); }); } catch(error) { capabilitiesContent.textContent=`The list of things you can ask is unavailable: ${error.message}`; } }); document.querySelector("#capabilities-close").addEventListener("click",()=>capabilitiesDialog.close()); document.querySelector("#insights-open").addEventListener("click",()=>{insightsDialog.showModal();loadInsights();}); document.querySelector("#maintenance-open").addEventListener("click",()=>{insightsDialog.showModal();loadInsights();}); document.querySelector("#insights-close").addEventListener("click",()=>insightsDialog.close()); -setInterval(async()=>{if(localStorage.getItem("boatChatBrowserAlerts")!=="on") return; const token=sessionStorage.getItem("boatChatAccessToken")||""; if(accessTokenRequired && !token) return; try {const response=await fetch("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); if(response.ok) processProactiveAlerts(await response.json());} catch(error) { /* Retry on the next interval. */ }},60000); +setInterval(async()=>{if(localStorage.getItem("boatChatBrowserAlerts")!=="on") return; const token=accessCode(); if(accessTokenRequired && !token) return; try {const response=await fetch("/api/insights",{headers:{"X-Boat-Chat-Token":token}}); if(response.ok) processProactiveAlerts(await response.json());} catch(error) { /* Retry on the next interval. */ }},60000); -document.querySelector("#settings-open").addEventListener("click", async () => { - settingsMessage.textContent = ""; - try { - await loadModelCatalog(); - await loadSettings(); - } catch (error) { - settingsMessage.textContent = `Load failed: ${error.message}`; - } - settingsDialog.showModal(); -}); +document.querySelector("#settings-open").addEventListener("click", openSettings); +document.querySelector("#settings-retry").addEventListener("click", openSettings); +document.querySelector("#settings-connect").addEventListener("click", () => { settingsDialog.close(); openConnect(); }); document.querySelector("#settings-close").addEventListener("click", () => { settingsDialog.close(); @@ -837,12 +981,25 @@ function addWelcomeMessage() { } addMessage( "assistant", - "Ask about the boat now, investigate a change, or review a trip. I’ll show the evidence with each answer." + "Ask about the boat now, look into a change, or review a trip. Each answer shows where its information came from." ); } -setContext(null); addWelcomeMessage(); renderSavedQuestions(); loadHealth(); loadBoatStatus(); + +// Header: on phones everything except the two view toggles lives under "More". +const moreMenu = document.querySelector(".more-menu"); +const narrowHeader = window.matchMedia("(max-width: 480px)"); +function syncMoreMenu() { moreMenu.open = !narrowHeader.matches; } +syncMoreMenu(); +narrowHeader.addEventListener("change", syncMoreMenu); +moreMenu.querySelector(".more-items").addEventListener("click", (event) => { + if (narrowHeader.matches && event.target.closest("button")) moreMenu.open = false; +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && narrowHeader.matches && moreMenu.open) { moreMenu.open = false; moreMenu.querySelector("summary").focus(); } +}); +showView(document.body.dataset.view || "overview"); diff --git a/boat-chat/static/index.html b/boat-chat/static/index.html index 6f57aea..48faeff 100644 --- a/boat-chat/static/index.html +++ b/boat-chat/static/index.html @@ -3,10 +3,11 @@ - + + VesselStack Boat Chat - +
@@ -14,32 +15,39 @@
-

VesselStack Boat Chat

-

Telemetry-backed assistant for VesselStack.

+

Boat Chat

+

Ask questions about your boat, answered from its own recorded data.

-
+
Starting
- - - - - - +
+ + +
+
+ More +
+ + + + +
+
- - - - + + + +
-

THEOFFICE · YOUR BOAT

Connect to your boat

+

YOUR BOAT

Connect to your boat

Connect this device to view boat observations.

Needs attention

Boat status has not been checked.

@@ -52,17 +60,10 @@

What would you like to do?

System status

Connect to check system status.

-
+
+
- - -
+
- + @@ -85,22 +86,27 @@

What would you like to do?

Connect this device

-

Enter your Boat Chat access token. It stays in this tab and is cleared when you disconnect.

- +

Enter your Boat Chat access code. It stays in this tab and is cleared when you disconnect.

+

-
+

Settings

+ +
- Routing + Models
-
+
+ Advanced +
+
Telegram
@@ -347,42 +356,44 @@

Settings

+
+
- - + +

What Boat Chat can do

-
Loading capabilities…
+
Loading the list of things you can ask…

Boat Hub

Safety, trip, position, and maintenance at a glance.

-
Loading boat insights…
+
Loading Boat Hub…
- + diff --git a/boat-chat/static/styles.css b/boat-chat/static/styles.css index 1bda73b..65af00f 100644 --- a/boat-chat/static/styles.css +++ b/boat-chat/static/styles.css @@ -1,21 +1,31 @@ +/* Brand tokens: light (default) and dark. Brand reference: brand/README.md. + Fonts load only from the visitor's system; there are no external requests. */ :root { color-scheme: light; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f3f6f7; - color: #10181d; - --bg: #f3f6f7; + --font-body: "Public Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-head: Archivo, "Public Sans", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-body); + background: var(--bg); + color: var(--text); + --bg: #f4f8fa; --panel: #ffffff; - --panel-soft: #e9eff2; - --border: #c4cdd2; - --border-strong: #7f9099; - --text: #10181d; - --muted: #40515b; - --accent: #075f7e; - --accent-strong: #064b64; - --accent-soft: #cce8f3; - --ok: #207456; - --warning: #8a5b12; - --danger: #a83f36; + --panel-soft: #eaf2f6; + --border: #d4e0e8; + --border-strong: #778b9b; + --text: #0a2341; + --muted: #526778; + --accent: #006d89; + --accent-strong: #00566d; + --accent-soft: #dceef3; + --on-accent: #ffffff; + --ok: #1e6f45; + --ok-soft: #e1f1e8; + --warning: #8a6218; + --warning-soft: #f8eed6; + --danger: #b3382e; + --danger-soft: #fae5e2; + --backdrop: rgba(10, 35, 65, 0.45); } * { @@ -151,13 +161,13 @@ h2 { display: inline-flex; align-items: center; justify-content: center; - border: 1px solid #b7c3cc; + border: 1px solid var(--ok); border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 700; color: var(--ok); - background: #f7fbf8; + background: var(--ok-soft); white-space: nowrap; } @@ -165,20 +175,21 @@ h2 { max-width: 260px; color: var(--text); background: var(--panel-soft); + border-color: var(--border-strong); overflow: hidden; text-overflow: ellipsis; } .status.is-busy { color: var(--warning); - background: #fff8ec; - border-color: #e1c58d; + background: var(--warning-soft); + border-color: var(--warning); } .status.is-error { color: var(--danger); - background: #fff2f0; - border-color: #e2aaa4; + background: var(--danger-soft); + border-color: var(--danger); } .examples { @@ -204,9 +215,9 @@ h2 { .status-card { min-height:62px; padding:9px 11px; text-align:left; border-radius:8px; background:var(--panel); } .status-card span { display:block; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.04em; } .status-card strong { display:block; margin-top:5px; font-size:14px; } -.status-card[data-tone="ok"] { border-color:#9fcdbd; } -.status-card[data-tone="warning"] { border-color:#e1c58d; } -.status-card[data-tone="danger"] { border-color:#e2aaa4; } +.status-card[data-tone="ok"] { border-color:var(--ok); } +.status-card[data-tone="warning"] { border-color:var(--warning); } +.status-card[data-tone="danger"] { border-color:var(--danger); } .messages { flex: 1; @@ -229,7 +240,7 @@ h2 { .message.user { margin-left: auto; background: var(--accent-soft); - border: 1px solid #75aabd; + border: 1px solid var(--accent); border-radius: 8px; padding: 11px 13px; } @@ -264,68 +275,6 @@ h2 { color: var(--muted); } -.context-drawer { - margin: 0 24px 14px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--panel); -} - -.context-drawer summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-height: 42px; - padding: 10px 12px; - color: var(--text); - cursor: pointer; - font-size: 13px; - font-weight: 700; - list-style: none; -} - -.context-drawer summary::-webkit-details-marker { - display: none; -} - -.context-drawer summary::after { - content: "+"; - flex: 0 0 auto; - color: var(--muted); - font-weight: 800; -} - -.context-drawer[open] summary::after { - content: "-"; -} - -#context-summary { - flex: 1; - min-width: 0; - color: var(--muted); - font-weight: 600; - text-align: right; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.answer-details { - width: 100%; - max-height: min(420px, 55vh); - margin: 0; - overflow: auto; - border-top: 1px solid var(--border); - padding: 12px; - background: var(--panel-soft); - font: 13px/1.45 inherit; - color: var(--text); -} - -.evidence-list { display:grid; gap:8px; margin:0; padding:12px; list-style:none; } -.evidence-list li { display:flex; justify-content:space-between; gap:12px; } -.evidence-list span { color:var(--muted); text-align:right; } .followups { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; } .followups button { min-height:32px; padding:6px 9px; font-size:12px; color:var(--accent-strong); } .capabilities-panel { min-width:min(700px,92vw); padding:18px; } @@ -337,14 +286,23 @@ h2 { .alert-list,.maintenance-list { display:grid; gap:7px; margin:0; padding:0; list-style:none; } .alert-row,.maintenance-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:7px 0; border-bottom:1px solid var(--border); } .alert-row:last-child,.maintenance-row:last-child { border-bottom:0; } -.alert-dot { width:9px; height:9px; border-radius:50%; background:#2a9d68; flex:0 0 auto; } -.alert-row[data-severity="warning"] .alert-dot { background:#d98a17; } +.alert-dot { width:9px; height:9px; border-radius:50%; background:var(--ok); flex:0 0 auto; } +.alert-row[data-severity="warning"] .alert-dot { background:var(--warning); } +.alert-row[data-severity="danger"] .alert-dot { background:var(--danger); } +.alert-row[data-severity="unknown"] .alert-dot { background:var(--panel); border:2px solid var(--muted); } .alert-label { display:flex; align-items:center; gap:8px; } .position-link { display:inline-block; margin-top:8px; } .maintenance-form { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; margin-top:12px; } .maintenance-form input { min-width:0; border:1px solid var(--border-strong); border-radius:6px; padding:8px; } -.maintenance-row[data-due="overdue"] { color:#a84033; } -.maintenance-row[data-due="soon"] { color:#9a6410; } +.maintenance-row[data-due="overdue"] { color:var(--danger); box-shadow:inset 4px 0 0 var(--danger); padding-left:10px; } +.maintenance-row[data-due="soon"] { color:var(--warning); box-shadow:inset 4px 0 0 var(--warning); padding-left:10px; } +.due-badge { display:inline-block; margin-left:6px; padding:1px 7px; border:1px solid currentColor; border-radius:999px; font-size:11px; font-weight:700; background:var(--panel); vertical-align:1px; } +.maintenance-row[data-due="overdue"] .due-badge { background:var(--danger-soft); } +.maintenance-row[data-due="soon"] .due-badge { background:var(--warning-soft); } +.maintenance-row input[type="checkbox"] { width:24px; min-height:24px; flex:0 0 auto; accent-color:var(--accent); } +.inline-form { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; align-items:center; } +.inline-form input { flex:1 1 180px; min-width:0; } +.form-error { color:var(--danger); font-size:13px; margin-top:8px; } .maintenance-copy { display:grid; gap:2px; flex:1; } .maintenance-copy small { color:var(--muted); } .trip-actions { display:flex; flex-wrap:wrap; gap:8px; } @@ -383,8 +341,8 @@ textarea { align-items: flex-end; gap: 8px; } -.mode-control { display:flex; flex-direction:column; gap:3px; color:var(--muted); font-size:10px; } -.mode-control select { min-height:32px; border:1px solid var(--border-strong); border-radius:6px; background:var(--panel); color:var(--text); } +.mode-control { display:flex; flex-direction:column; gap:3px; color:var(--muted); font-size:12px; font-weight:600; } +.mode-control select { min-height:44px; border:1px solid var(--border-strong); border-radius:6px; background:var(--panel); color:var(--text); } .composer button { align-self: end; @@ -394,7 +352,7 @@ textarea { .composer #send-button { background: var(--accent); border-color: var(--accent); - color: #ffffff; + color: var(--on-accent); font-weight: 720; } @@ -419,7 +377,7 @@ dialog { } dialog::backdrop { - background: rgba(23, 32, 38, 0.45); + background: var(--backdrop); } .dialog-header, @@ -465,16 +423,19 @@ dialog::backdrop { } .settings-section[data-provider-config] { - order: 10; - background: var(--panel-soft); -} - -.settings-section.is-relevant { - order: 1; border-left-color: var(--accent); background: var(--panel-soft); } +.settings-advanced { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); } +.settings-advanced > summary { min-height: 44px; display: flex; align-items: center; padding: 8px 13px; cursor: pointer; font-size: 13px; font-weight: 760; } +.settings-advanced-body { display: grid; gap: 12px; padding: 0 13px 13px; } +.settings-error { display: grid; gap: 12px; padding: 16px; } +.settings-error p { color: var(--danger); } +.settings-error .followups { margin-top: 0; } +form[data-state="error"] .settings-grid, form[data-state="error"] #settings-save { display: none; } +form[data-state="ok"] .settings-error { display: none; } + .settings-section legend { padding: 0 6px; font-size: 13px; @@ -497,7 +458,7 @@ dialog::backdrop { } .route-summary span { - border: 1px solid #b7c3cc; + border: 1px solid var(--border-strong); border-radius: 999px; padding: 5px 9px; font-size: 12px; @@ -609,10 +570,6 @@ select { max-width: 100%; } - .context-drawer { - margin: 0 14px 12px; - } - .composer { grid-template-columns: 1fr; gap: 10px; @@ -654,7 +611,6 @@ html[data-embedded="ha"] .app-header { } html[data-embedded="ha"] .header-brand p, -html[data-embedded="ha"] .brand-vessel, html[data-embedded="ha"] .status-secondary { display: none; } @@ -675,7 +631,8 @@ html[data-embedded="ha"] .status-stack { flex-wrap: nowrap; } -html[data-embedded="ha"] .header-actions > button { +html[data-embedded="ha"] .header-actions button, +html[data-embedded="ha"] .more-menu > summary { min-height: 34px; padding: 6px 9px; font-size: 12px; @@ -699,10 +656,6 @@ html[data-embedded="ha"] .messages { padding: 12px; } -html[data-embedded="ha"] .context-drawer { - margin: 0 12px 8px; -} - html[data-embedded="ha"] .composer { gap: 8px; padding: 9px 12px max(10px, env(safe-area-inset-bottom)); @@ -744,9 +697,6 @@ html[data-embedded="ha"] textarea { padding: 10px; } - html[data-embedded="ha"] .context-drawer { - margin-inline: 10px; - } } @media (max-width: 420px) { @@ -754,81 +704,12 @@ html[data-embedded="ha"] textarea { display: none; } - html[data-embedded="ha"] .header-actions > button { + html[data-embedded="ha"] .header-actions button, +html[data-embedded="ha"] .more-menu > summary { padding-inline: 7px; } } -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --bg: #091014; - --panel: #151e23; - --panel-soft: #202c32; - --border: #52636c; - --border-strong: #83949c; - --text: #f7fafb; - --muted: #c4d0d5; - --accent: #1685aa; - --accent-strong: #79c7e1; - --accent-soft: #17495c; - --ok: #82d9b5; - --warning: #f1c36e; - --danger: #ff9d94; - } - - button:hover, - .secondary-button { - background: #2b3940; - } - - button, - input, - select, - textarea, - dialog, - .message.assistant, - .message.user, - .context-drawer, - .settings-section, - .status-card { - color: #ffffff; - } - - .message.assistant, - textarea, - input, - select, - dialog, - .context-drawer, - .settings-section, - .status-card { - background: #111a1f; - } - - .message.user { - background: #174f64; - border-color: #76c8e3; - } - - .status, - .status-secondary, - .route-summary span, - .answer-details, - .settings-section[data-provider-config] { - background: #1d292f; - color: var(--text); - } - - .status.is-busy { - background: #372e1d; - } - - .status.is-error { - background: #3b2423; - } -} - @media (min-width: 1180px) { .message.assistant { max-width: 980px; @@ -851,7 +732,7 @@ html[data-embedded="ha"] textarea { .status-card[data-tone="unknown"] { border-style:dashed; color:var(--muted); } .owner-overview[data-outdated="true"] .status-card { border-style:dashed; } #observation-age { color:var(--muted); } -body[data-view="overview"] .messages, body[data-view="overview"] .examples, body[data-view="overview"] .context-drawer { display:none; } +body[data-view="overview"] .messages, body[data-view="overview"] .examples { display:none; } body[data-view="chat"] .owner-overview { display:none; } #connect-dialog { max-width:480px; } #connect-dialog input { width:100%; margin-top:8px; } @@ -882,21 +763,47 @@ body[data-view="overview"] #capabilities-open, body[data-view="overview"] #insig .message-evidence ul { padding-left:1.3em; } .feedback-controls { flex-wrap:wrap; } -/* Chart-room presentation tokens; existing selectors retain behavior. */ -:root { --bg:#f4f8fa; --panel:#fff; --panel-soft:#eaf2f6; --border:#d4e0e8; --border-strong:#778b9b; --text:#0a2341; --muted:#526778; --accent:#006d89; --accent-strong:#00566d; --accent-soft:#dceef3; --ok:#1e6f45; --warning:#8a6218; --danger:#b3382e; font-family:"Public Sans",ui-sans-serif,system-ui,sans-serif; } +/* Chart-room presentation: non-token rules. */ body { color:var(--text); background:var(--bg); } -h1,h2,h3 { font-family:Archivo,ui-sans-serif,system-ui,sans-serif; } button,input,select,textarea { min-height:44px; border-radius:8px; } -button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible { outline:3px solid #006d89; outline-offset:2px; } -@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; } } +h1,h2,h3 { font-family:var(--font-head); } button,input,select,textarea { min-height:44px; border-radius:8px; } +code,pre { font-family:var(--font-mono); } +button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible { outline:3px solid var(--accent); outline-offset:2px; } +@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; scroll-behavior:auto!important; } } .header-icon { width:42px; height:42px; border-radius:8px; vertical-align:middle; margin-right:10px; } .header-brand { display:flex; align-items:center; gap:10px; } @media (prefers-color-scheme:dark) { - :root { --bg:#0a1826; --panel:#10253a; --panel-soft:#173247; --border:#36536a; --border-strong:#7892a5; --text:#eaf2f6; --muted:#b3c5d0; --accent:#4aaebe; --accent-strong:#72c6ce; --accent-soft:#173d50; --ok:#78c99b; --warning:#e5bd68; --danger:#f08a80; } -body { background:var(--bg); color:var(--text); } - .app-header,.examples,.composer,.message.assistant,.context-drawer,dialog,.dialog-header,.dialog-footer,.settings-section { background:var(--panel); color:var(--text); } - button,input,select,textarea { background:var(--panel); color:var(--text); } - .message.user { background:var(--accent-soft); border-color:var(--border); } - .answer-details,pre,.secondary-button { background:var(--panel-soft); color:var(--text); } - .composer #send-button { color:#04121c; } - button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible { outline-color:#4aaebe; } -} + :root { + color-scheme: dark; + --bg:#0a1826; --panel:#10253a; --panel-soft:#173247; --border:#36536a; --border-strong:#7892a5; + --text:#eaf2f6; --muted:#b3c5d0; --accent:#4aaebe; --accent-strong:#72c6ce; --accent-soft:#173d50; + --on-accent:#04121c; --ok:#78c99b; --ok-soft:#16382d; --warning:#e5bd68; --warning-soft:#3a3015; + --danger:#f08a80; --danger-soft:#3c2222; --backdrop:rgba(2, 8, 14, 0.7); + } +} + +/* Header: view toggles stay visible; the other buttons collapse into "More" on phones. */ +.view-toggle { display:flex; gap:6px; } +.view-toggle button[aria-pressed="true"] { background:var(--accent-soft); border-color:var(--accent); color:var(--text); font-weight:700; box-shadow:inset 0 -3px 0 var(--accent); } +.more-menu { position:relative; } +.more-menu > summary { display:none; align-items:center; justify-content:center; min-height:44px; padding:9px 12px; border:1px solid var(--border-strong); border-radius:8px; background:var(--panel); cursor:pointer; list-style:none; } +.more-menu > summary::-webkit-details-marker { display:none; } +.more-items { display:flex; flex-wrap:wrap; align-items:center; gap:10px; } +@media (max-width:480px) { + .more-menu > summary { display:inline-flex; } + .more-menu[open] > summary { background:var(--panel-soft); } + .more-items { position:absolute; right:0; top:calc(100% + 6px); z-index:5; flex-direction:column; align-items:stretch; min-width:190px; padding:8px; background:var(--panel); border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px var(--backdrop); } + .header-actions { justify-content:space-between; } + .view-toggle { flex:1 1 auto; } + .view-toggle button { flex:1 1 0; } + .examples button { white-space:nowrap; } +} +@media (max-width:480px) { + html[data-embedded="ha"] .more-items { top:calc(100% + 4px); } +} +.messages:focus-visible { outline:3px solid var(--accent); outline-offset:-3px; } +.count-hint { color:var(--muted); } +.setting-help { color:var(--muted); font-size:12px; font-weight:400; line-height:1.35; } +.radar-ring { fill:none; stroke:var(--border-strong); stroke-width:1; } +.radar-own { fill:currentColor; } +.radar-target { fill:var(--accent-strong); } +.radar-target.is-stale { fill:var(--warning); } diff --git a/control-panel/app.py b/control-panel/app.py index a093443..dfc4ff7 100644 --- a/control-panel/app.py +++ b/control-panel/app.py @@ -85,28 +85,28 @@ def field( FIELDS = [ field("BOAT_NAME", "Boat name", "Vessel", description="Display name used across VesselStack."), - field("BOAT_TYPE", "Boat type", "Vessel"), + field("BOAT_TYPE", "Boat type", "Vessel", description="Free text shown to people and Boat Chat, for example Motor vessel, Sailing yacht or Catamaran. It does not change how anything is installed."), field("BOAT_MMSI", "MMSI", "Vessel", kind="secret", description="Optional private vessel identity; never commit it."), field("BOAT_CALLSIGN", "Call sign", "Vessel", kind="secret", description="Optional private vessel identity; never commit it."), field("BOAT_LOA_M", "Length overall (m)", "Vessel", kind="number"), field("BOAT_BEAM_M", "Beam (m)", "Vessel", kind="number"), - field("BOAT_TIMEZONE", "Timezone", "Vessel", description="IANA timezone such as America/Los_Angeles."), - field("BOAT_UNITS", "Display units", "Vessel", kind="select", choices=["us_customary", "metric"]), + field("BOAT_TIMEZONE", "Timezone (for example Europe/Lisbon)", "Vessel", description="A region and city name from the standard timezone list, such as Europe/Lisbon or America/Los_Angeles. Used for timestamps and daily summaries."), + field("BOAT_UNITS", "Display units", "Vessel", kind="select", choices=["us_customary", "metric"], description="US customary shows feet, degrees F, psi and US gallons; metric shows metres, degrees C, kPa and litres. Speeds stay in knots and distances in nautical miles either way."), field("VESSELSTACK_USER", "Service account", "Installation", read_only=True, description="Change from the recovery shell with a reviewed migration."), field("VESSELSTACK_ROOT", "Application path", "Installation", kind="path", read_only=True, description="Change from the recovery shell with a reviewed migration."), field("VESSELSTACK_DATA", "Data path", "Installation", kind="path", read_only=True, description="Relocate data only with a stopped stack and verified backup."), field("VESSELSTACK_BACKUP", "Backup path", "Installation", kind="path"), field("VESSELSTACK_DISABLED_COMPONENTS", "Disabled components", "Installation", read_only=True, description="Read-only membership summary; use Components to add or remove core services."), - field("SIGNALK_MODE", "SignalK mode", "SignalK", kind="select", choices=["existing", "docker", "native"]), - field("SIGNALK_VERSION", "SignalK version", "SignalK"), - field("SIGNALK_URL", "SignalK URL", "SignalK", kind="url"), - field("SIGNALK_VESSELTWIN_PRELOAD", "Preload VesselTwin plugin", "SignalK", kind="boolean", description="Managed Docker/native modes install the pinned signalk-vesseltwin plugin before SignalK starts. Existing mode is never modified; false skips installation and never uninstalls."), - field("SOCKETCAN_ENABLE", "Enable SocketCAN", "SignalK", kind="boolean"), - field("SOCKETCAN_INTERFACE", "CAN interface", "SignalK"), - field("SOCKETCAN_BITRATE", "CAN bitrate", "SignalK", kind="number"), + field("SIGNALK_MODE", "Signal K mode", "Signal K", kind="select", choices=["existing", "docker", "native"], description="Docker: VesselStack runs Signal K for you in a container (recommended). Existing: you already run a Signal K server and VesselStack only connects to it. Native: Signal K is installed directly on this Pi. Changing it takes effect when you apply the configuration."), + field("SIGNALK_VERSION", "Signal K version", "Signal K", description="Release tag of the Signal K container image, for example v2.27.0."), + field("SIGNALK_URL", "Signal K URL", "Signal K", kind="url", description="Where VesselStack reaches Signal K. Needed when the mode is Existing."), + field("SIGNALK_VESSELTWIN_PRELOAD", "Preload VesselTwin plugin", "Signal K", kind="boolean", description="Managed Docker/native modes install the pinned signalk-vesseltwin plugin before Signal K starts. Existing mode is never modified; false skips installation and never uninstalls."), + field("SOCKETCAN_ENABLE", "Enable SocketCAN", "Signal K", kind="boolean", description="Reads the NMEA 2000 network through a CAN interface. Enable it only after the CAN hardware is installed and tested."), + field("SOCKETCAN_INTERFACE", "CAN interface", "Signal K", description="Name of the CAN interface on this Pi, usually can0."), + field("SOCKETCAN_BITRATE", "CAN bitrate", "Signal K", kind="number", description="Speed of the CAN bus in bits per second. NMEA 2000 always uses 250000; change it only for a non-marine CAN network."), field("AIS_ENABLE", "Enable AIS-catcher", "AIS", kind="boolean"), field("AIS_IMAGE", "AIS image", "AIS"), - field("AIS_DEVICE", "AIS device", "AIS", kind="path"), + field("AIS_DEVICE", "AIS device", "AIS", kind="path", description="Where the USB AIS receiver appears on this Pi. The default, /dev/bus/usb, works for most single receivers."), field("AIS_CATCHER_ARGS", "AIS-catcher arguments", "AIS"), field("AIS_WEB_PORT", "AIS web port", "AIS", kind="port"), field("AIS_TCP_PORT", "AIS TCP output port", "AIS", kind="port"), @@ -115,7 +115,7 @@ def field( field("INFLUXDB_URL", "InfluxDB URL", "InfluxDB", kind="url"), field("VESSELSTACK_DATA_BIND_ADDRESS", "Data services bind address", "Network", description="InfluxDB, Prometheus and MQTT publish on this IPv4 address. Keep 127.0.0.1 unless external devices must connect over a trusted network."), field("VESSELSTACK_WEB_BIND_ADDRESS", "Web UI bind address", "Network", description="Grafana and Heimdall publish on this IPv4 address (0.0.0.0 = every interface, 127.0.0.1 = this device only)."), - field("HOMEASSISTANT_BIND_ADDRESS", "Home Assistant bind address", "Network", description="0.0.0.0 = every interface, 127.0.0.1 = this device only. Applied through a managed http block in Home Assistant's configuration.yaml by the installer or `vesselstackctl lan`; SignalK has no such setting."), + field("HOMEASSISTANT_BIND_ADDRESS", "Home Assistant bind address", "Network", description="0.0.0.0 = every interface, 127.0.0.1 = this device only. Applied through a managed http block in Home Assistant's configuration.yaml by the installer or `vesselstackctl lan`; Signal K has no such setting."), field("INFLUXDB_PORT", "Published port", "InfluxDB", kind="port"), field("INFLUXDB_CONTAINER_NAME", "Container name", "InfluxDB"), field("INFLUXDB_ORG", "Organization", "InfluxDB"), @@ -139,15 +139,15 @@ def field( field("DISPLAYS_ENABLE", "Enable instrument displays", "Displays", kind="boolean", description="Adds the scoped bridge and per-display read-only MQTT accounts to the Mosquitto access list."), field("NODE_EXPORTER_ENABLE", "Enable host metrics", "Prometheus", kind="boolean", description="Runs the optional hardened host metrics exporter on the private Compose metrics profile."), field("DISPLAYS_BRIDGE_MQTT_USERNAME", "Displays bridge MQTT username", "Displays", description="Letters, numbers, dot, underscore and hyphen; must differ from the MQTT username."), - field("DISPLAYS_SIGNALK_TOKEN", "Displays bridge SignalK token", "Displays", kind="secret", description="Optional. Sent as a bearer token when SignalK requires authentication; leave blank otherwise."), + field("DISPLAYS_SIGNALK_TOKEN", "Displays bridge Signal K token", "Displays", kind="secret", description="Optional. Sent as a bearer token when Signal K requires authentication; leave blank otherwise."), field("DISPLAYS_HA_TOKEN", "Displays bridge Home Assistant token", "Displays", kind="secret", description="Optional, only for Home Assistant items (ha) in displays.json; leave blank otherwise. A leaked token can operate every device in your Home Assistant; a non-admin user only prevents configuration changes. Keep it on the Pi (root-only), never reuse it, and revoke it in Home Assistant (Profile -> Security) if it leaks."), field("VESSELSTACK_FIREWALL_ENABLE", "Enable interface firewall", "Network", kind="boolean"), field("VESSELSTACK_UNTRUSTED_INTERFACE", "Untrusted interface", "Network"), field("VESSELSTACK_FIREWALL_STRICT", "Strict firewall (allow-list only)", "Network", kind="boolean", description="Drops everything on the untrusted interface except the allow-lists; keep another way to reach the device."), field("VESSELSTACK_FIREWALL_ALLOW_TCP", "Strict mode allowed TCP ports", "Network", description="Comma-separated ports or LOW:HIGH ranges; default 22."), field("VESSELSTACK_FIREWALL_ALLOW_UDP", "Strict mode allowed UDP ports", "Network", description="Comma-separated ports or LOW:HIGH ranges; default none."), - field("CONTROL_PANEL_HOST", "Control panel host", "Control Panel", description="Keep 127.0.0.1 for local-only access. For a trusted LAN, use `sudo vesselstackctl panel-access lan`; this exposes unencrypted HTTP on every interface and may still be blocked by a strict firewall. Roll back with `sudo vesselstackctl panel-access local`."), - field("CONTROL_PANEL_PORT", "Control panel port", "Control Panel", kind="port"), + field("CONTROL_PANEL_HOST", "Control Panel host", "Control Panel", description="Keep 127.0.0.1 for local-only access. For a trusted LAN, use `sudo vesselstackctl panel-access lan`; this exposes unencrypted HTTP on every interface and may still be blocked by a strict firewall. Roll back with `sudo vesselstackctl panel-access local`."), + field("CONTROL_PANEL_PORT", "Control Panel port", "Control Panel", kind="port"), field("BOAT_CHAT_PROVIDER", "Primary provider", "Boat Chat", source="chat", kind="select", choices=["local", "codex_cli", "claude_cli", "openai", "vercel", "bedrock", "google", "ollama", "openai_compatible"]), field("BOAT_CHAT_MODEL", "Primary model", "Boat Chat", source="chat"), field("BOAT_CHAT_FALLBACK_PROVIDER", "Fallback provider", "Boat Chat", source="chat", kind="select", choices=["", "local", "codex_cli", "claude_cli", "openai", "vercel", "bedrock", "google", "ollama", "openai_compatible"]), diff --git a/control-panel/static/app.js b/control-panel/static/app.js index 6cdc7f8..1466dd5 100644 --- a/control-panel/static/app.js +++ b/control-panel/static/app.js @@ -22,6 +22,7 @@ const state = { installedVersion: "unknown", runningVersion: "unknown", releaseCheckedAt: "", + rollbackPath: "", }; const $ = (selector) => document.querySelector(selector); @@ -42,12 +43,32 @@ async function api(path, options = {}) { return payload; } +const OPTION_LABELS = { + BOAT_UNITS: { us_customary: "US customary", metric: "Metric" }, + SIGNALK_MODE: { docker: "Docker (built-in)", existing: "Existing server", native: "Native (on this Pi)" }, + PROVIDER: { "": "None", local: "Local (built-in)", codex_cli: "Codex CLI", claude_cli: "Claude CLI", openai: "OpenAI", vercel: "Vercel AI Gateway", bedrock: "Amazon Bedrock", google: "Google Gemini", ollama: "Ollama", openai_compatible: "OpenAI-compatible API" }, +}; +const optionLabel = (key, choice) => { + const table = OPTION_LABELS[key] || (key.startsWith("BOAT_CHAT_") && key.endsWith("PROVIDER") ? OPTION_LABELS.PROVIDER : null); + return (table && table[choice]) || choice || "None"; +}; +const OPERATION_NAMES = { + apply: "Apply configuration", install: "Install and start", preflight: "Preflight check", backup: "Backup", + "bootstrap-history": "Provision history", update: "Reviewed release update", "update-release": "Release update", + "restart-panel": "Control Panel restart", start: "Start", stop: "Stop", restart: "Restart", +}; +const operationName = (label) => OPERATION_NAMES[label] || String(label || "Operation"); +const componentName = (component) => String(component.label || component.id || "").replace(/^Signalk\b/, "Signal K"); +const LIVE_DATA_NOTE = "Live instrument data will pause briefly."; + function toast(message, isError = false) { const node = $("#toast"); - node.textContent = message; + $("#toast-text").textContent = message; node.className = isError ? "show error" : "show"; + $("#toast-close").hidden = !isError; clearTimeout(node.timer); - node.timer = setTimeout(() => { node.className = ""; }, 4200); + // Errors stay until dismissed; confirmations fade on their own. + if (!isError) node.timer = setTimeout(() => { node.className = ""; }, 4200); } function setConnected(connected) { @@ -71,6 +92,8 @@ function lock() { state.pendingOperation = null; state.lastOperationId = null; state.bootId = null; + state.rollbackPath = ""; + $("#toast").className = ""; $("#config-form").innerHTML = ""; $("#operation-output").textContent = "No operation has run in this session."; $("#operation-state").textContent = "Idle"; @@ -128,8 +151,8 @@ function renderReleases(data) { state.releases = Array.isArray(data.releases) ? data.releases.filter((release) => Number.isInteger(release.id) && release.tag && release.version && release.name && releaseUrl(release.html_url)) : []; $("#release-versions").textContent = `Installed: ${state.installedVersion} · Running: ${state.runningVersion} · Checked ${state.releaseCheckedAt}`; $("#release-warning").hidden = data.channel !== "lab"; - $("#release-warning").textContent = data.channel === "lab" ? "Lab releases are for testing. Read the release notes before installing." : ""; - $("#release-select").innerHTML = state.releases.length ? state.releases.map((release) => ``).join("") : ""; + $("#release-warning").textContent = data.channel === "lab" ? "Early test (lab) releases are for testing. Read the release notes before installing." : ""; + $("#release-select").innerHTML = state.releases.length ? state.releases.map((release) => ``).join("") : ""; updateReleaseControls(); updateReleaseDetail(); } @@ -175,7 +198,7 @@ function operationSucceeded(operation) { async function installSelectedRelease() { const release = selectedRelease(); if (!release || state.busy) return; - if (isConfigDirty() && !window.confirm("Configuration has unsaved edits. Save them first; installation will not discard them.")) return; + if (isConfigDirty() && !window.confirm("You have unsaved configuration edits. They will be kept but not applied by this update. Continue?")) return; if (!window.confirm(`Back up and install ${release.version}? Services and this panel may be interrupted, and a writable backup mount is required.`)) return; startOperation({ action: "update-release", release_id: release.id, channel: state.releaseChannel }); } @@ -239,7 +262,7 @@ function renderComponents(components) { const query = state.componentQuery.toLowerCase(); const filtered = state.components.filter((component) => { const availability = component.enabled ? "enabled" : "available"; - const haystack = `${component.label || component.id} ${component.description || ""}`.toLowerCase(); + const haystack = `${componentName(component)} ${component.description || ""}`.toLowerCase(); return (state.componentFilter === "all" || state.componentFilter === availability) && (!query || haystack.includes(query)); }); const active = state.components.filter((item) => item.enabled && statusClass(item.state) === "active").length; @@ -250,10 +273,10 @@ function renderComponents(components) { const stateLabel = escapeHtml(component.state || "unknown"); const availabilityLabel = disabled ? "available" : "enabled"; const componentId = escapeHtml(component.id); - const componentLabel = escapeHtml(component.label); + const componentLabel = escapeHtml(componentName(component)); const description = escapeHtml(component.description || (component.kind === "container" ? "Docker Compose service" : "Host systemd service")); let actions; - const fallbackSections = { "signalk-native": "SignalK", socketcan: "SignalK", firewall: "Network", "ais-catcher": "AIS" }; + const fallbackSections = { "signalk-native": "Signal K", socketcan: "Signal K", firewall: "Network", "ais-catcher": "AIS" }; const configureSection = component.section || fallbackSections[component.id]; const configure = configureSection ? `` : ""; const controls = ``; @@ -368,31 +391,32 @@ function groupFields(fields) { function fieldMarkup(spec, value) { const id = `setting-${spec.key}`; - const help = spec.description ? `${spec.description}` : ""; + const help = spec.description ? `${escapeHtml(spec.description)}` : ""; + const described = spec.description ? ` aria-describedby="${id}-help"` : ""; const source = spec.source === "chat" ? "Boat Chat" : "VesselStack"; const sourceNote = `${source}${spec.read_only ? " · read-only" : ""}`; let input; if (spec.kind === "select") { - input = ``; + input = ``; } else if (spec.kind === "boolean") { - input = ``; + input = ``; } else { const type = spec.kind === "secret" ? "password" : ["number", "port"].includes(spec.kind) ? "number" : "text"; const shown = spec.kind === "secret" ? "" : escapeHtml(value || ""); const placeholder = spec.kind === "secret" && value?.configured ? "Configured — leave blank to keep" : ""; const numeric = spec.kind === "number" ? 'step="any"' : spec.kind === "port" ? 'step="1" min="1" max="65535"' : ""; - input = ``; + input = ``; } const clear = spec.kind === "secret" && value?.configured && !spec.read_only ? `` : ""; - return `
${input}${clear}${help}
`; + return `
${input}${clear}${help}
`; } function renderConfiguration(config) { const sections = groupFields(config.fields); $("#config-form").innerHTML = Object.entries(sections).map(([name, fields]) => ` -

${name}

${fields.map((spec) => fieldMarkup(spec, config.values[spec.key])).join("")}
+

${escapeHtml(name)}

${fields.map((spec) => fieldMarkup(spec, config.values[spec.key])).join("")}
`).join(""); $("#config-form").oninput = () => updateConfigStatus(); $("#config-form").onchange = () => updateConfigStatus(); @@ -438,7 +462,10 @@ function isConfigDirty() { function updateConfigStatus() { const dirty = isConfigDirty(); - $("#config-status").textContent = dirty ? "Unsaved changes" : "All changes saved"; + const rollback = state.rollbackPath ? ` Rollback copy of the previous configuration: ${state.rollbackPath}` : ""; + $("#config-status").textContent = (dirty ? "Unsaved changes." : "All changes saved.") + rollback; + $("#config-status").classList.toggle("dirty", dirty); + $("#apply-config").disabled = dirty || state.busy || state.saving; $("#save-config").disabled = !dirty || state.busy || state.saving; $("#discard-config").disabled = !dirty || state.busy || state.saving; } @@ -470,6 +497,8 @@ async function saveConfiguration() { if (session !== state.session || !state.token) return; state.config = result.configuration; renderConfiguration(state.config); + if (result.backup) state.rollbackPath = result.backup; + updateConfigStatus(); toast(result.backup ? `Configuration saved. Rollback copy: ${result.backup}` : "Configuration saved. Apply it when ready."); } catch (error) { if (session !== state.session || !state.token) return; @@ -493,7 +522,7 @@ async function startOperation(payload) { if (session !== state.session || !state.token) return; if (state.pendingOperation) state.pendingOperation.operationId = operation.id ?? null; renderOperation(operation); - toast(`Started: ${operation.label}`); + toast(`${operationName(operation.label)} started. Progress appears in the operation log below.`); refreshStatus(); } catch (error) { if (session !== state.session || !state.token) return; @@ -516,7 +545,9 @@ function runAction(action, extra = {}) { const confirmations = { install: "Install and start the configured VesselStack components?", apply: "Apply the saved configuration to the installed system?", - stop: "Stop the managed VesselStack services? The control panel will remain available.", + start: `Start the managed VesselStack services? ${LIVE_DATA_NOTE}`, + restart: `Restart the managed VesselStack services? ${LIVE_DATA_NOTE}`, + stop: "Stop the managed VesselStack services? The Control Panel will remain available.", update: "Create a backup and apply this reviewed release? The panel and services may be interrupted, and a writable backup mount is required.", }; if (confirmations[action] && !window.confirm(confirmations[action])) return; @@ -524,8 +555,12 @@ function runAction(action, extra = {}) { } function runComponent(component, action) { - if (action === "remove" && !window.confirm(`Remove ${component}? Existing data is preserved, but integrations may stop working.`)) return; - if (action === "stop" && !window.confirm(`Stop ${component}?`)) return; + const known = state.components.find((item) => item.id === component); + const name = known ? componentName(known) : component; + if (action === "remove" && !window.confirm(`Remove ${name}? Existing data is preserved, but integrations may stop working.`)) return; + if (action === "stop" && !window.confirm(`Stop ${name}? ${LIVE_DATA_NOTE}`)) return; + if (action === "restart" && !window.confirm(`Restart ${name}? ${LIVE_DATA_NOTE}`)) return; + if (action === "start" && !window.confirm(`Start ${name}? ${LIVE_DATA_NOTE}`)) return; startOperation({ component, action }); } @@ -546,9 +581,23 @@ $("#login-form").addEventListener("submit", (event) => { unlock(); }); $$('[data-tab]').forEach((button) => button.addEventListener("click", () => { - $$('[data-tab]').forEach((item) => item.classList.toggle("active", item === button)); + $$('[data-tab]').forEach((item) => { + const selected = item === button; + item.classList.toggle("active", selected); + item.setAttribute("aria-selected", String(selected)); + item.tabIndex = selected ? 0 : -1; + }); $$('.tab-panel').forEach((panel) => { panel.hidden = panel.id !== button.dataset.tab; }); })); +$$('[data-tab]').forEach((button, index, tabs) => button.addEventListener("keydown", (event) => { + const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (!step) return; + event.preventDefault(); + const target = tabs[(index + step + tabs.length) % tabs.length]; + target.click(); + target.focus(); +})); +$("#toast-close").addEventListener("click", () => { $("#toast").className = ""; }); $$('[data-action]').forEach((button) => button.addEventListener("click", () => runAction(button.dataset.action))); $("#refresh").addEventListener("click", refreshStatus); $("#save-config").addEventListener("click", saveConfiguration); @@ -558,16 +607,20 @@ $("#check-releases").addEventListener("click", checkReleases); $("#include-lab").addEventListener("change", checkReleases); $("#release-select").addEventListener("change", updateReleaseDetail); $("#install-release").addEventListener("click", installSelectedRelease); -$("#restart-panel").addEventListener("click", () => { if (window.confirm("Restart the control panel? The panel will briefly disconnect.")) startOperation({ action: "restart-panel" }); }); +$("#restart-panel").addEventListener("click", () => { if (window.confirm("Restart the Control Panel? The panel will briefly disconnect.")) startOperation({ action: "restart-panel" }); }); $("#lock").addEventListener("click", lock); $("#component-search").addEventListener("input", (event) => { state.componentQuery = event.target.value; renderComponents(state.components); }); $$('[data-component-filter]').forEach((button) => button.addEventListener("click", () => { state.componentFilter = button.dataset.componentFilter; - $$('[data-component-filter]').forEach((item) => item.classList.toggle("active", item === button)); + $$('[data-component-filter]').forEach((item) => { + item.classList.toggle("active", item === button); + item.setAttribute("aria-pressed", String(item === button)); + }); renderComponents(state.components); })); -$$('[data-tab]').forEach((button) => button.addEventListener("click", () => { - if (button.dataset.tab === "configuration" && state.config) setTimeout(() => $("#config-form input:not([disabled]), #config-form select:not([disabled])")?.focus(), 0); +$$('[data-tab]').forEach((button) => button.addEventListener("click", (event) => { + // Only a pointer click moves focus into the form; keyboard tab navigation stays on the tab. + if (button.dataset.tab === "configuration" && state.config && event.detail > 0) setTimeout(() => $("#config-form input:not([disabled]), #config-form select:not([disabled])")?.focus(), 0); })); window.addEventListener("beforeunload", (event) => { if (isConfigDirty()) { event.preventDefault(); event.returnValue = ""; } }); diff --git a/control-panel/static/index.html b/control-panel/static/index.html index 8d42dc4..70bdd2d 100644 --- a/control-panel/static/index.html +++ b/control-panel/static/index.html @@ -3,8 +3,9 @@ - - + + + VesselStack Control Panel @@ -20,11 +21,11 @@
-
-
+
diff --git a/control-panel/static/styles.css b/control-panel/static/styles.css index f335b63..1b841b4 100644 --- a/control-panel/static/styles.css +++ b/control-panel/static/styles.css @@ -1,51 +1,68 @@ +/* VesselStack Control Panel. Brand tokens: brand/README.md. Dark palette matches Boat Chat. No external requests. */ :root { - --ink: #eef8f7; - --muted: #91aaa8; - --panel: #102421; - --panel-2: #152e2a; - --line: #29433e; - --sea: #4ed6b1; - --sea-dark: #163c33; - --amber: #f5bf66; - --red: #ff7b72; - --bg: #081714; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color-scheme: light dark; + --bg: #f4f8fa; --panel: #ffffff; --panel-2: #eaf2f6; --input-bg: #ffffff; + --ink: #0a2341; --secondary: #344c61; --muted: #526778; + --line: #d4e0e8; --strong: #778b9b; + --sea: #006d89; --sea-dark: #00566d; --on-sea: #ffffff; --sea-soft: #dceef3; + --success: #1e6f45; --amber: #8a6218; --red: #b3382e; --on-red: #ffffff; + --hover-bg: rgba(0, 109, 137, .08); --topbar-bg: rgba(255, 255, 255, .96); + --toast-bg: #0a2341; --toast-ink: #ffffff; --toast-border: #0a2341; + --shadow: 0 14px 34px rgba(10, 35, 65, .07); --focus: #006d89; + --font-body: "Public Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-head: Archivo, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-family: var(--font-body); +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0a1826; --panel: #10253a; --panel-2: #173247; --input-bg: #0a1826; + --ink: #eaf2f6; --secondary: #eaf2f6; --muted: #b3c5d0; + --line: #36536a; --strong: #7892a5; + --sea: #4aaebe; --sea-dark: #72c6ce; --on-sea: #0a1826; --sea-soft: #173d50; + --success: #78c99b; --amber: #e5bd68; --red: #f08a80; --on-red: #0a1826; + --hover-bg: rgba(74, 174, 190, .14); --topbar-bg: rgba(16, 37, 58, .96); + --toast-bg: #173247; --toast-ink: #eaf2f6; --toast-border: #7892a5; + --shadow: 0 14px 34px rgba(0, 0, 0, .35); --focus: #72c6ce; + } } * { box-sizing: border-box; } -body { margin: 0; min-height: 100vh; color: var(--ink); background: radial-gradient(circle at 15% 0%, #17332e 0, transparent 30rem), var(--bg); } +body { margin: 0; min-height: 100vh; color: var(--ink); background: var(--bg); } button, input, select { font: inherit; } -button { min-height: 2.65rem; padding: .7rem 1rem; color: var(--ink); background: transparent; border: 1px solid var(--line); border-radius: .65rem; cursor: pointer; transition: .15s ease; } -button:hover { border-color: var(--sea); background: rgba(78, 214, 177, .08); } -button:disabled { opacity: .45; cursor: not-allowed; } -button.primary { color: #06221b; font-weight: 750; background: var(--sea); border-color: var(--sea); } -button.primary:hover { background: #75e4c5; } +button { min-height: 44px; padding: .7rem 1rem; color: var(--ink); background: transparent; border: 1px solid var(--strong); border-radius: 8px; cursor: pointer; transition: .15s ease; } +button:hover { border-color: var(--sea); background: var(--hover-bg); } +button:disabled { color: var(--muted); border-color: var(--line); background: var(--panel-2); opacity: .7; cursor: not-allowed; } +button.primary { color: var(--on-sea); font-weight: 750; background: var(--sea); border-color: var(--sea); } +button.primary:hover { color: var(--on-sea); background: var(--sea-dark); border-color: var(--sea-dark); } +button.primary:disabled { color: var(--muted); background: var(--panel-2); border-color: var(--line); } button.danger { color: var(--red); } -input, select { width: 100%; min-height: 2.65rem; padding: .65rem .75rem; color: var(--ink); background: #0a1d1a; border: 1px solid var(--line); border-radius: .55rem; } -input:focus, select:focus { outline: 2px solid rgba(78, 214, 177, .32); border-color: var(--sea); } -input:disabled, select:disabled { color: #78918e; background: #0d1917; cursor: not-allowed; } +input, select { width: 100%; min-height: 44px; padding: .65rem .75rem; color: var(--ink); background: var(--input-bg); border: 1px solid var(--strong); border-radius: 8px; } +input::placeholder { color: var(--muted); opacity: 1; } +input:disabled, select:disabled { color: var(--muted); background: var(--panel-2); cursor: not-allowed; } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +a { color: var(--sea); } label { display: block; margin-bottom: .4rem; font-size: .86rem; font-weight: 650; } -.field-source { float: right; color: var(--muted); font-size: .68rem; font-weight: 500; letter-spacing: .02em; } -code { display: block; overflow-x: auto; padding: .8rem; color: var(--muted); background: #07110f; border-radius: .5rem; } -.topbar { height: 4.75rem; padding: 0 clamp(1rem, 4vw, 4rem); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(8, 23, 20, .8); backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 5; } -.topbar-actions { display: flex; align-items: center; gap: 1rem; } -.quiet { min-height: 2rem; padding: .35rem .65rem; font-size: .78rem; } +.field-source { float: right; color: var(--muted); font-size: .72rem; font-weight: 500; letter-spacing: .02em; } +code { display: block; overflow-x: auto; padding: .8rem; color: var(--secondary); background: var(--panel-2); border-radius: 8px; font-family: var(--font-mono); font-size: .85rem; } +.topbar { height: 4.75rem; padding: 0 clamp(1rem, 4vw, 4rem); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: var(--topbar-bg); backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 5; } .brand { display: flex; align-items: center; gap: .75rem; } -.brand .brand-mark { display: grid; place-items: center; width: 2.4rem; height: 2.4rem; color: #06221b; font-weight: 900; background: var(--sea); border-radius: .55rem; } +.brand .brand-mark { display: block; width: 2.4rem; height: 2.4rem; border-radius: .55rem; } .brand strong, .brand span { display: block; } .brand div span { color: var(--muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; } .connection { display: flex; align-items: center; gap: .55rem; color: var(--muted); font-size: .85rem; } -.dot { width: .55rem; height: .55rem; background: var(--amber); border-radius: 50%; box-shadow: 0 0 0 .25rem rgba(245, 191, 102, .12); } -.dot.online { background: var(--sea); box-shadow: 0 0 0 .25rem rgba(78, 214, 177, .12); } +.dot { width: .55rem; height: .55rem; background: var(--amber); border-radius: 50%; box-shadow: 0 0 0 .25rem var(--hover-bg); } +.dot.online { background: var(--success); } main { width: min(1240px, calc(100% - 2rem)); margin: 0 auto; padding: clamp(2rem, 5vw, 5rem) 0 5rem; } -.card { background: linear-gradient(145deg, rgba(21, 46, 42, .94), rgba(13, 31, 27, .94)); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 24px 70px rgba(0,0,0,.18); } +.card { background: var(--panel); border: 1px solid var(--line); border-radius: 1rem; box-shadow: var(--shadow); } .login { max-width: 650px; margin: 7vh auto; padding: clamp(1.5rem, 5vw, 3.5rem); } h1, h2, h3, p { margin-top: 0; } +h1, h2, h3 { font-family: var(--font-head); } h1 { max-width: 820px; margin-bottom: 1rem; font-size: clamp(2.2rem, 6vw, 5.5rem); line-height: .98; letter-spacing: -.045em; } .login h1 { font-size: clamp(2.2rem, 5vw, 4rem); } h2 { margin-bottom: .2rem; font-size: clamp(1.65rem, 3vw, 2.3rem); letter-spacing: -.025em; } h3 { margin-bottom: .5rem; } p { color: var(--muted); line-height: 1.6; } -.eyebrow { margin-bottom: .75rem; color: var(--sea); font-size: .7rem; font-weight: 800; letter-spacing: .15em; } +.eyebrow { margin-bottom: .75rem; color: var(--sea); font-size: .72rem; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; } .token-row, .inline-actions { display: flex; gap: .6rem; } .token-row { margin-bottom: 1rem; } .error { margin-top: 1rem; color: var(--red); } @@ -59,37 +76,36 @@ p { color: var(--muted); line-height: 1.6; } .overview { display: grid; grid-template-columns: repeat(4, 1fr); gap: .75rem; margin-bottom: .9rem; } .overview div { padding: .9rem 1rem; background: var(--panel); border: 1px solid var(--line); border-radius: .7rem; } .overview strong, .overview span { display: block; } -.overview strong { font-size: 1.5rem; } +.overview strong { font-family: var(--font-mono); font-size: 1.5rem; } .overview span, .catalog-note { color: var(--muted); font-size: .8rem; } .catalog-note { margin: 0 0 1.1rem; } .component-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } .search-field { width: min(26rem, 100%); margin: 0; } .filter-group { display: flex; gap: .35rem; } -.filter-group button { min-height: 2.2rem; padding: .45rem .7rem; font-size: .8rem; } +.filter-group button { padding: .45rem .7rem; font-size: .8rem; } .filter-group button.active { color: var(--sea); border-color: var(--sea); } -.empty-state { grid-column: 1 / -1; padding: 2.4rem; text-align: center; background: var(--panel); border: 1px dashed var(--line); border-radius: .85rem; } +.empty-state { grid-column: 1 / -1; padding: 2.4rem; text-align: center; background: var(--panel); border: 1px dashed var(--strong); border-radius: .85rem; } .empty-state p { margin: .35rem 0 0; } -.form-status { min-height: 1.4rem; margin: -.3rem 0 .8rem; color: var(--amber); font-size: .82rem; } -.component { min-width: 0; min-height: 175px; padding: 1.2rem; display: flex; flex-direction: column; justify-content: space-between; background: var(--panel); border: 1px solid var(--line); border-radius: .85rem; } +.form-status { min-height: 1.4rem; margin: -.3rem 0 .8rem; color: var(--muted); font-size: .82rem; overflow-wrap: anywhere; } +.form-status.dirty { color: var(--amber); } +.component { min-width: 0; min-height: 175px; padding: 1.2rem; display: flex; flex-direction: column; justify-content: space-between; background: var(--panel); border: 1px solid var(--line); border-radius: .85rem; box-shadow: var(--shadow); } .component-top { display: flex; align-items: flex-start; justify-content: space-between; gap: .5rem; } .component-top h3 { min-width: 0; overflow-wrap: anywhere; } .component-status { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: .35rem; min-width: 0; } -.availability { color: var(--muted); font-size: .68rem; text-transform: capitalize; } -.component h3 { text-transform: capitalize; } +.availability { color: var(--muted); font-size: .72rem; text-transform: capitalize; } .component p { margin: 0; font-size: .82rem; } .component-actions { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.25rem; } -.component-actions button { min-width: 0; min-height: 2.2rem; flex: 1 1 auto; padding: .45rem .65rem; font-size: .8rem; } -.pill { display: inline-flex; align-items: center; width: fit-content; padding: .25rem .55rem; color: var(--muted); background: #0a1d1a; border: 1px solid var(--line); border-radius: 999px; font-size: .72rem; text-transform: capitalize; } -.pill.active, .pill.running, .pill.succeeded { color: var(--sea); border-color: rgba(78, 214, 177, .35); } -.pill.failed { color: var(--red); border-color: rgba(255, 123, 114, .35); } +.component-actions button { min-width: 0; flex: 1 1 auto; padding: .45rem .65rem; font-size: .8rem; } +.pill { display: inline-flex; align-items: center; width: fit-content; padding: .25rem .55rem; color: var(--secondary); background: var(--panel-2); border: 1px solid var(--line); border-radius: 999px; font-size: .75rem; text-transform: capitalize; } +.pill.active, .pill.running, .pill.succeeded { color: var(--success); border-color: var(--success); } +.pill.failed { color: var(--red); border-color: var(--red); } .pill.disabled { color: var(--amber); } -.notice { margin-bottom: 1.2rem; padding: 1rem; color: var(--muted); background: rgba(78, 214, 177, .06); border: 1px solid var(--line); border-radius: .7rem; line-height: 1.5; } -.config-section { margin-bottom: 1rem; padding: clamp(1rem, 3vw, 1.6rem); background: var(--panel); border: 1px solid var(--line); border-radius: .85rem; } +.notice { margin-bottom: 1.2rem; padding: 1rem; color: var(--secondary); background: var(--panel-2); border: 1px solid var(--line); border-radius: .7rem; line-height: 1.5; } +.config-section { margin-bottom: 1rem; padding: clamp(1rem, 3vw, 1.6rem); background: var(--panel); border: 1px solid var(--line); border-radius: .85rem; box-shadow: var(--shadow); } .config-section h3 { margin-bottom: 1.2rem; color: var(--sea); } .field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); gap: 1.15rem; } -.field small { display: block; margin-top: .35rem; color: var(--muted); line-height: 1.35; } -.field.secret input::placeholder { color: #708b87; } -.clear-secret { display: flex; align-items: center; gap: .45rem; margin: .45rem 0 0; color: var(--muted); font-size: .75rem; font-weight: 500; } +.field small { display: block; margin-top: .35rem; color: var(--muted); line-height: 1.4; } +.clear-secret { display: flex; align-items: center; gap: .45rem; margin: .45rem 0 0; color: var(--muted); font-size: .78rem; font-weight: 500; } .clear-secret input { width: auto; min-height: auto; } .maintenance-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; } .action-card { padding: 1.3rem; } @@ -100,22 +116,27 @@ p { color: var(--muted); line-height: 1.6; } .action-card > button { width: 100%; } .action-card input { margin-bottom: .7rem; } .release-toolbar { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; margin-bottom: .8rem; } -.release-toolbar .check-label { display: flex; align-items: center; gap: .45rem; margin: 0; color: var(--muted); font-size: .78rem; font-weight: 500; } +.release-toolbar .check-label { display: flex; align-items: center; gap: .45rem; margin: 0; color: var(--muted); font-size: .8rem; font-weight: 500; } .release-toolbar .check-label input { width: auto; min-height: auto; } .release-versions, .release-detail, .operation-progress { min-height: 1.4rem; margin: .45rem 0 .8rem; font-size: .8rem; } +.release-versions { font-family: var(--font-mono); } .release-detail a { color: var(--sea); } .release-warning { margin: .6rem 0 .8rem; color: var(--amber); font-size: .8rem; } .release-progress { min-height: 1.5rem; margin-top: .8rem; color: var(--amber); font-size: .82rem; } .advanced-update { margin-top: 1.15rem; padding-top: 1rem; border-top: 1px solid var(--line); } -.advanced-update summary { color: var(--muted); cursor: pointer; font-size: .8rem; } +.advanced-update summary { color: var(--muted); cursor: pointer; font-size: .85rem; } .advanced-update p { margin: .8rem 0; font-size: .8rem; } .advanced-update button { width: 100%; } .operation { margin-top: 3rem; padding: clamp(1rem, 3vw, 1.5rem); } .operation-progress { color: var(--amber); } -.operation pre { min-height: 9rem; max-height: 28rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #bcd1ce; background: #050d0c; border-radius: .6rem; line-height: 1.55; } -#toast { position: fixed; right: 1rem; bottom: 1rem; max-width: min(28rem, calc(100vw - 2rem)); padding: .9rem 1rem; background: var(--ink); color: #071612; border-radius: .7rem; box-shadow: 0 14px 40px rgba(0,0,0,.35); transform: translateY(6rem); transition: .2s ease; z-index: 10; } -#toast.show { transform: translateY(0); } -#toast.error { color: white; background: #7e302d; } +.operation pre { min-height: 9rem; max-height: 28rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: var(--secondary); background: var(--panel-2); border-radius: .6rem; line-height: 1.55; font-family: var(--font-mono); font-size: .85rem; } +#toast { position: fixed; right: 1rem; bottom: 1rem; display: flex; align-items: center; gap: .8rem; max-width: min(28rem, calc(100vw - 2rem)); padding: .9rem 1rem; color: var(--toast-ink); background: var(--toast-bg); border: 1px solid var(--toast-border); border-radius: .7rem; box-shadow: 0 14px 40px rgba(0, 0, 0, .35); transform: translateY(6rem); visibility: hidden; transition: .2s ease; z-index: 10; } +#toast.show { transform: translateY(0); visibility: visible; } +#toast.error { color: var(--on-red); background: var(--red); border-color: var(--red); } +#toast-text { min-width: 0; overflow-wrap: anywhere; } +#toast button { flex: 0 0 auto; min-height: 36px; padding: .3rem .7rem; color: inherit; border-color: currentColor; font-size: .8rem; } +#toast button:hover { background: rgba(255, 255, 255, .18); } +#toast button:focus-visible { outline-color: currentColor; } @media (max-width: 720px) { .hero { grid-template-columns: 1fr; } .hero-actions { grid-template-columns: 1fr 1fr; } @@ -128,10 +149,4 @@ p { color: var(--muted); line-height: 1.6; } .component-toolbar { align-items: stretch; flex-direction: column; } .search-field { width: 100%; } } - -/* Chart-room presentation tokens; existing selectors retain behavior. */ -:root { --ink:#0a2341; --muted:#526778; --secondary:#344c61; --panel:#ffffff; --panel-2:#eaf2f6; --line:#d4e0e8; --sea:#006d89; --sea-dark:#00566d; --amber:#8a6218; --red:#b3382e; --bg:#f4f8fa; --success:#1e6f45; font-family:"Public Sans",ui-sans-serif,system-ui,sans-serif; } -body { background:var(--bg); } h1,h2,h3 { font-family:Archivo,ui-sans-serif,system-ui,sans-serif; } .brand-mark { display:block; background:transparent; color:transparent; } -body,.topbar { background:var(--bg); color:var(--ink); }.topbar { background:rgba(255,255,255,.96); }.card,.component,.config-section { background:var(--panel); box-shadow:0 14px 34px rgba(10,35,65,.07); } input,select { background:#fff; color:var(--ink); } input:disabled,select:disabled { background:var(--panel-2); color:var(--muted); } code,.operation pre,.pill { background:var(--panel-2); color:var(--secondary); } button.primary { color:#fff; background:var(--sea); } button.primary:hover { color:#fff; background:var(--sea-dark); } .dot.online { background:var(--success); } .pill.active,.pill.running,.pill.succeeded { color:var(--success); } #toast { background:var(--ink); color:#fff; } -button,input,select { min-height:44px; border-radius:8px; } button:focus-visible,input:focus-visible,select:focus-visible { outline:3px solid #006d89; outline-offset:2px; } -@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms!important; animation-duration:.01ms!important; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; } } diff --git a/docs/BOAT_CHAT.md b/docs/BOAT_CHAT.md index 22d11ae..647ef20 100644 --- a/docs/BOAT_CHAT.md +++ b/docs/BOAT_CHAT.md @@ -4,19 +4,20 @@ Purpose: first-run access, settings, telemetry refresh, host metrics, and unit a [Back to README](../README.md) · [Documentation index](README.md) -Open `http://HOST:8765`. Chat requires the access token +Open `http://HOST:8765`. Chat requires the access code (`BOAT_CHAT_ACCESS_TOKEN` in `config/boat-chat.env`) and refuses requests without it unless `BOAT_CHAT_ALLOW_OPEN=true` is set. The `local` provider returns gathered context without sending it to an external model. Configure an optional provider in Settings. -Retrieve the settings token locally when needed: +Retrieve the admin code (the settings token) locally when needed: ```bash sudo grep '^BOAT_CHAT_SETTINGS_TOKEN=' \ /opt/vesselstack/config/boat-chat.env ``` -Boat Chat retains this settings credential only for the current browser tab; -closing the tab clears it. +Boat Chat retains the access code and admin code only for the current browser tab; +closing the tab clears them. The web app labels them **Access code** and **Admin code**; +the environment variable names (`BOAT_CHAT_ACCESS_TOKEN`, `BOAT_CHAT_SETTINGS_TOKEN`) are unchanged. Boat Chat rejects oversized or malformed API requests and sends defensive browser headers while remaining embeddable in the Home Assistant dashboard. @@ -52,7 +53,7 @@ time so slow model calls cannot exhaust the Raspberry Pi. The installer generates `BOAT_CHAT_ACCESS_TOKEN` (random, preserved on re-install) and writes it to `config/boat-chat.env`. Chat, feedback, session, live status, insights, and maintenance endpoints all require it in the -`X-Boat-Chat-Token` header; the web UI asks for it through **Connect** and keeps +`X-Boat-Chat-Token` header; the web UI asks for the access code through **Connect** and keeps it only in session storage, and the Telegram bridge sends it automatically. Find it with: @@ -63,7 +64,7 @@ sudo grep '^BOAT_CHAT_ACCESS_TOKEN=' /opt/vesselstack/config/boat-chat.env An empty token **fails closed**: all of those endpoints return 401. A reviewed, trusted installation can opt out by adding `BOAT_CHAT_ALLOW_OPEN=true` to `config/boat-chat.env`; this key is deliberately not editable from the Settings -page. `BOAT_CHAT_REQUIRE_ACCESS_TOKEN` is retired and no longer disables +page. `BOAT_CHAT_REQUIRE_ACCESS_TOKEN` is retired and does not disable protection. ## Settings and configuration precedence @@ -80,15 +81,24 @@ the same way (`boat-chat/env_config.py`): Saving a value on the Settings page that equals the base value stores no override. Saving a Boat Chat field in the control panel removes that key from the override file so the new value takes effect, and the panel marks any field -that is currently overridden. Settings writes require `BOAT_CHAT_SETTINGS_TOKEN` -from any address; with no settings token they are accepted from loopback only -(private-LAN addresses are no longer trusted). `TELEGRAM_ALLOWED_CHAT_IDS` is a +that is currently overridden. `GET` and `POST /api/settings` require the access code. +Settings writes also require the admin code (`BOAT_CHAT_SETTINGS_TOKEN`) from any +address; with no admin code they are accepted from loopback only (private-LAN +addresses are not trusted). The access code goes in `X-Boat-Chat-Token`, or, when it +differs from the admin code, in the optional `X-Boat-Chat-Access-Token` header while +`X-Boat-Chat-Token` carries the admin code; `GET`, `HEAD` and `POST` all accept that header. +The access code cannot be cleared through the API (blank or `null` returns 400) unless +`BOAT_CHAT_ALLOW_OPEN=true`; set a new one instead. A save that blanks +`BOAT_CHAT_PROVIDER`, `BOAT_CHAT_MODEL`, `BOAT_CHAT_MAX_TOKENS`, `BOAT_CHAT_CONTEXT_CHARS`, +`BOAT_CHAT_HOST` or `BOAT_CHAT_PORT`, or gives an invalid number, returns 400. The +Settings page only opens once the current values have loaded and saves only the fields +you changed. `TELEGRAM_ALLOWED_CHAT_IDS` is a secret setting and is never returned by `GET /api/settings`. Settings updates are serialized and written atomically. Blank secret fields keep the saved value; an explicit JSON `null` stores an empty override so an -inherited credential cannot reappear. Use the current settings token to -authenticate a save and the separate replacement field to rotate it. Tokens +inherited credential cannot reappear. Use the current admin code to +authenticate a save and the separate replacement field to rotate it. The codes remain in this browser tab's session storage. JSON write requests require `Content-Type: application/json`; supplied browser origins must match the request authority. @@ -187,7 +197,7 @@ release packages. Settings values containing a newline, carriage return or NUL byte are rejected (HTTP 400, nothing written), matching the control panel. `vesselstackctl urls` reports the **real** bind state of each service from the -listening sockets (`ss -ltn`), so the host-networked SignalK (always) and Home Assistant +listening sockets (`ss -ltn`), so the host-networked Signal K (always) and Home Assistant (unless limited by `HOMEASSISTANT_BIND_ADDRESS`) show up as listening on every interface although their client URLs say `127.0.0.1`. It prints this machine's reachable addresses (`HOSTNAME.local` and every non-loopback IPv4 address, ignoring Docker bridges and veth links) as @@ -225,7 +235,7 @@ descriptor). `root:` mode 0750: the service user can traverse it but cannot create, rename, replace or symlink anything directly in it. Only the leaf mount roots belong to the accounts that must write them: `influxdb` (uid/gid 1000), -`grafana` (uid 472, gid 0) and `boat-chat` (service user; native SignalK state +`grafana` (uid 472, gid 0) and `boat-chat` (service user; native Signal K state `signalk` likewise; with `SIGNALK_MODE=docker` `signalk` is uid/gid 1000, the `node` user of the signalk-server image, created before `compose up`; a root-owned one left by Docker is chowned to 1000:1000 on rerun, non-recursively diff --git a/docs/BOM.md b/docs/BOM.md index 675ec88..13e8ec1 100644 --- a/docs/BOM.md +++ b/docs/BOM.md @@ -18,6 +18,7 @@ manufacturer instructions before ordering. | Network | Ethernet cable preferred; Wi-Fi optional | Use Ethernet for initial setup and stable telemetry where practical. | | Backup | Separate USB SSD/flash drive | Must mount outside `VESSELSTACK_DATA`; remove or replicate off-boat periodically. | | Optional AIS | Linux-compatible USB AIS receiver and antenna system | VesselStack supports AIS-catcher only when explicitly enabled. | +| Optional display (preview) | Waveshare ESP32-S3-Touch-LCD-2.8C, one per display | Shows Signal K values from the Pi over MQTT on a 2.8 inch round touch screen. It is separate from the NMEA interface and runs only with `DISPLAYS_ENABLE=true`; see [Instrument displays](DISPLAYS.md). | ## NMEA interface diff --git a/docs/CONTROL_PANEL.md b/docs/CONTROL_PANEL.md index df3a6fd..da300b5 100644 --- a/docs/CONTROL_PANEL.md +++ b/docs/CONTROL_PANEL.md @@ -50,7 +50,7 @@ then be updated with the new credential. Saving settings writes mode-600 configuration atomically but does not restart services. Review changes, run **Preflight**, then use **Apply configuration** or -**Install & start**. Hardware probes and the existing SignalK connectivity +**Install & start**. Hardware probes and the existing Signal K connectivity check still gate installer actions. Listener changes require a shell restart: ```bash diff --git a/docs/CONTROL_PANEL_TESTING.md b/docs/CONTROL_PANEL_TESTING.md index 7f4ba7e..6e7b270 100644 --- a/docs/CONTROL_PANEL_TESTING.md +++ b/docs/CONTROL_PANEL_TESTING.md @@ -1,6 +1,6 @@ # Control Panel component lifecycle lab test -This is the release 0.5.7 lab recipe. It is a bounded acceptance test, not +This is the release 0.6.0 lab recipe. It is a bounded acceptance test, not evidence of a boat or physical Raspberry Pi test. Use a scratch lab Pi or an isolated VM with Docker and a mounted data disk. Keep real vessel identity, positions, MMSIs, tokens, logs, and backups out of this repository. @@ -8,30 +8,30 @@ positions, MMSIs, tokens, logs, and backups out of this repository. ## Fetch and verify the pinned prerelease Download the pinned bootstrap and the CI-generated checksum from the same -`v0.5.7` GitHub prerelease. Verify before reading or running it: +`v0.6.0` GitHub release. Verify before reading or running it: ```bash -curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh -curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh.sha256 +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256 sha256sum -c get-vesselstack.sh.sha256 less get-vesselstack.sh sudo bash get-vesselstack.sh --help ``` -The release is an annotated unsigned lab tag, not a signed stable release. The -checksum catches a corrupt or truncated asset; it does not replace review of -the release provenance. +0.6.0 release status (signed or unsigned tag, prerelease or Latest): recorded in the +CHANGELOG at release time. The checksum catches a corrupt or truncated asset; it does not +replace review of the release provenance. ## Exercise the browser updater Open the authenticated panel and use **Check for updates**. Confirm the catalog is `jclima/VesselStack`, stable releases are the default, and **Include lab -prereleases** is opt-in. Because the installed `0.5.7` release is current, it -may correctly report **No newer release**; do not downgrade, retag, or force the -same version. Exercise **Back up & install** only when a newer public lab -release is available. A fresh `0.5.7` install is a CLI install; an existing -`0.5.5` or `0.5.6` install may use the panel upgrade path only after enabling -**Include lab prereleases**. CI-controlled fixtures exercise the install +prereleases** is opt-in. When the installed `0.6.0` release is current, the panel +correctly reports **No newer release**; do not downgrade, retag, or force the +same version. Exercise **Back up & install** only when a newer public release +is available. A fresh `0.6.0` install is a CLI install; an existing `0.5.5`, +`0.5.6` or `0.5.7` install may use the panel upgrade path, after enabling +**Include lab prereleases** if 0.6.0 is published as a prerelease. CI-controlled fixtures exercise the install orchestration, and the actual systemd restart is covered by the backend verifier. Full physical-Pi upgrade proof remains pending physical testing. Mount the configured external backup device first. Confirm the backup path is diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 43f4d3c..c2a0894 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ -# Deployment runbook: VesselStack 0.5.7 to a home-lab Pi and the boat Pi +# Deployment runbook: VesselStack 0.6.0 to a home-lab Pi and the boat Pi -This is an operator runbook for taking release 0.5.7 through two targets: +This is an operator runbook for taking release 0.6.0 through two targets: 1. **Home lab**: Raspberry Pi 4B, internet-connected, used for staging, a 48 h+ soak and rehearsals (CAN replay through `vcan0`). @@ -23,13 +23,14 @@ the scripts in this repository; where a thing does not exist the text says so. Related: [INSTALLATION.md](INSTALLATION.md) (hardware and first install), [README.md](../README.md) (reference for every variable and command). -Release status: `v0.5.7` is the authorized lab prerelease and is deliberately -not latest; `v0.4.4` remains the latest stable release. Physical Raspberry Pi -testing remains pending. This exception uses an annotated unsigned tag because -no signing key is available. The earlier -`v0.5.1` tag remains untouched; its release pipeline was blocked by the bridge -startup-status race. Do not retag or promote any 0.5.x lab prerelease tag as stable; a future -stable release needs a new signed tag after the real-Pi gates pass. +0.6.0 release status: recorded in the CHANGELOG at release time. Check the release page +and the tag (section 1) before you rely on either. Physical Raspberry Pi acceptance is a +separate gate (section 7). + +History: `v0.5.7` was published as an unsigned lab prerelease that was not marked latest, and +`v0.4.4` stayed the release GitHub marks Latest through the 0.5.x series. The `v0.5.1` tag is +untouched; its release pipeline was blocked by the bridge startup-status race. Published tags +are never recreated, moved or promoted by retagging. ## Simple path: one command @@ -40,10 +41,10 @@ carrying bundles, rollback and the acceptance checklist. **Home lab (internet-connected Pi).** On the Pi: ```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` -(`.../releases/latest/download/get-vesselstack.sh` works only once a release is marked latest.) +(`.../releases/latest/download/get-vesselstack.sh` points to whichever release GitHub marks Latest, which may not be 0.6.0; use the pinned URL above.) Do it over plain SSH (`ssh -t YOUR_USER@LAB_PI`): no screen, browser or tunnel is needed. It installs prerequisites, asks before installing Docker, verifies the release sha256 (compare it with the value recorded in section 2), asks the configuration @@ -59,18 +60,18 @@ read it, then `sudo bash get-vesselstack.sh`. Without questions (scripts, no ter browser wizard instead of the terminal questions. **Boat (offline Pi).** The boat has no internet, so carry the files in (section 4): -`get-vesselstack.sh`, `vesselstack-0.5.7.tar.gz` with its `.sha256`, and the +`get-vesselstack.sh`, `vesselstack-0.6.0.tar.gz` with its `.sha256`, and the offline bundle (copy the bundle root-owned, `chmod -R go-w`). Install Docker and the apt prerequisites while the Pi still has internet (the script cannot install packages offline and tells you what is missing), then: -For a first offline install, review the managed SignalK preload and displays +For a first offline install, review the managed Signal K preload and displays bridge prerequisites in section 3.5.3; a bundle alone cannot provide a first offline plugin or bridge-virtualenv installation. ```bash sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle \ - --tarball /path/to/vesselstack-0.5.7.tar.gz + --tarball /path/to/vesselstack-0.6.0.tar.gz ``` It verifies the tarball checksum and the bundle (`prepare-offline-bundle.sh @@ -125,10 +126,53 @@ fetches the pinned `origin` into a root-owned mirror and requires the upstream branch tip to have a valid commit signature **or** a validly signed tag pointing at it. It needs network access to `origin`, so it is a home-lab/connected-machine flow. **`install.sh` and `vesselstackctl update` do not verify signatures** of the -tarball or directory they are given. On the boat, this `v0.5.7` lab recipe uses -the sha256 recorded at build time (section 2) and the recorded commit; future -signed releases also require the signed tag to be verified on the maintainer -machine. +tarball or directory they are given. On the boat, this recipe uses +the sha256 recorded at build time (section 2) and the recorded commit; a signed tag, when +the release has one, is verified on the maintainer machine. + +### Signing key in 1Password (recommended) + +Keeping the signing key in 1Password means the private key never leaves 1Password, +and each signature needs the owner's approval (biometric or password prompt). + +1. In 1Password, create an **SSH Key** item (Ed25519) and enable the 1Password SSH agent + (Settings, Developer, "Use the SSH agent"). +2. Configure git for this repository only, so other repositories are unaffected. + `` is the one-line public key shown on the 1Password item + (`ssh-ed25519 AAAA...`): + + ```bash + git config --local gpg.format ssh + git config --local user.signingkey "key::" + git config --local gpg.ssh.program /Applications/1Password.app/Contents/MacOS/op-ssh-sign + git config --local tag.gpgSign true + ``` + + On Linux the program is `/opt/1Password/op-ssh-sign`. +3. Create an allowed-signers file so `git verify-tag` works locally. The principal must + equal your verified `user.email`: + + ```bash + printf '%s namespaces="git" %s\n' "" "" > allowed_signers + git config --local gpg.ssh.allowedSignersFile "$PWD/allowed_signers" + ``` + + Install the same file on each Pi as described below. +4. Add the **public** key to GitHub as a **Signing Key** (Settings, SSH and GPG keys, New + SSH key, key type "Signing Key"). The tagger email must be a verified email on the + account for the tag to show the Verified badge. +5. Point terminals at the 1Password agent, because `ssh-add` and `gh` do not read the + `IdentityAgent` line in `~/.ssh/config`: + + ```bash + export SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" # macOS + ``` + +Then `git tag -s vX.Y.Z -m "VesselStack X.Y.Z"` prompts for approval in 1Password, and +`git verify-tag vX.Y.Z` checks it. The file-based instructions below remain as the +alternative when 1Password is not available. + +### Signing key as a file (alternative) On the **maintainer machine** (private key never leaves it): @@ -179,23 +223,20 @@ hatch; never set it on the boat. The boat has no route to `origin`, so `vesselstack-update` is not the boat's update path; leave it unconfigured there unless you later add a connection you trust. -Once `v0.5.7` is published, do not recreate or move the tag. On the maintainer +Once `v0.6.0` is published, do not recreate or move the tag. On the maintainer machine, verify that the published tag points at the intended merged commit; do not force-fetch or overwrite a published tag: ```bash git fetch origin main --tags -git show --no-patch --decorate v0.5.7 -git rev-parse v0.5.7^{commit} -git merge-base --is-ancestor v0.5.7^{commit} origin/main +git show --no-patch --decorate v0.6.0 +git rev-parse v0.6.0^{commit} +git merge-base --is-ancestor v0.6.0^{commit} origin/main ``` -For this explicitly authorized lab prerelease, `v0.5.7` will be an annotated, -unsigned tag: no SSH/GPG signing key is available. Do not claim -`git verify-tag` success, and do not use this exception for a stable release. -For a future release, follow the tag-creation and signing gates in -[DEVELOPMENT.md](DEVELOPMENT.md#release-checklist); future stable versions -require a cryptographically signed tag. +Record in the CHANGELOG whether `v0.6.0` is a signed or an annotated unsigned tag, and claim +`git verify-tag` success only for a tag that passes it. Follow the tag-creation and signing +gates in [DEVELOPMENT.md](DEVELOPMENT.md#release-checklist). ## 2. Cut and build @@ -210,29 +251,29 @@ From a clean clone, so no stray files can matter (`build-package.sh` packages ```bash git clone https://github.com/jclima/VesselStack.git vs-release && cd vs-release -git checkout v0.5.7 -cat VERSION # 0.5.7 +git checkout v0.6.0 +cat VERSION # 0.6.0 VESSELSTACK_BUILD_REQUIRE_CLEAN=true ./build-package.sh ``` -It writes `generated/vesselstack-0.5.7.tar.gz` and -`generated/vesselstack-0.5.7.tar.gz.sha256`, and prints the sha256 and +It writes `generated/vesselstack-0.6.0.tar.gz` and +`generated/vesselstack-0.6.0.tar.gz.sha256`, and prints the sha256 and `commit `. **Record both values** in your release notes (out of band from the tarball): they are the boat's acceptance check. ```bash -git rev-parse v0.5.7^{commit} # must equal the printed commit -tar -tzf generated/vesselstack-0.5.7.tar.gz | head -20 -tar -xzOf generated/vesselstack-0.5.7.tar.gz vesselstack-0.5.7/COMMIT -tar -xzOf generated/vesselstack-0.5.7.tar.gz vesselstack-0.5.7/BUILD_INFO -tar -tzf generated/vesselstack-0.5.7.tar.gz | grep -E '\.env$|boat_facts|\.venv|memory/|data/' \ +git rev-parse v0.6.0^{commit} # must equal the printed commit +tar -tzf generated/vesselstack-0.6.0.tar.gz | head -20 +tar -xzOf generated/vesselstack-0.6.0.tar.gz vesselstack-0.6.0/COMMIT +tar -xzOf generated/vesselstack-0.6.0.tar.gz vesselstack-0.6.0/BUILD_INFO +tar -tzf generated/vesselstack-0.6.0.tar.gz | grep -E '\.env$|boat_facts|\.venv|memory/|data/' \ || echo "no private artifacts" python3 scripts/check-public-tree.py ``` (`vesselstack.example.env` is expected in the listing; a real `*.env` is not.) `scripts/update-requirements-lock.sh` is for maintainers when -`boat-chat/requirements.txt` changes; for an unchanged 0.5.7 the lock ships in +`boat-chat/requirements.txt` changes; for an unchanged 0.6.0 the lock ships in the tarball and you do not run it. ## 3. Home lab (Pi 4B, connected) @@ -242,14 +283,14 @@ the tarball and you do not run it. Copy the tarball to the lab Pi, check it, unpack it root-owned: ```bash -scp generated/vesselstack-0.5.7.tar.gz* YOUR_USER@LAB_PI:~/ +scp generated/vesselstack-0.6.0.tar.gz* YOUR_USER@LAB_PI:~/ # on LAB_PI: -sha256sum -c vesselstack-0.5.7.tar.gz.sha256 # [read-only] +sha256sum -c vesselstack-0.6.0.tar.gz.sha256 # [read-only] sudo install -d -m 0755 /var/lib/vesselstack/releases # [MUTATES HOST] -sudo tar -xzf vesselstack-0.5.7.tar.gz -C /var/lib/vesselstack/releases --no-same-owner -sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.5.7 -sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.5.7 -cd /var/lib/vesselstack/releases/vesselstack-0.5.7 +sudo tar -xzf vesselstack-0.6.0.tar.gz -C /var/lib/vesselstack/releases --no-same-owner +sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.6.0 +sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.6.0 +cd /var/lib/vesselstack/releases/vesselstack-0.6.0 ``` Configuration: either the terminal wizard (`python3 vesselstack-wizard.py --cli --output ~/vesselstack-lab.env`, over plain @@ -287,7 +328,7 @@ sudo ./install.sh --config ~/vesselstack-lab.env # [MUTATES HOST] re sudo ./install.sh --config ~/vesselstack-lab.env --start # [MUTATES HOST] pulls images, starts stack sudo vesselstackctl doctor sudo vesselstackctl urls -cat /opt/vesselstack/config/installed-version # 0.5.7 +cat /opt/vesselstack/config/installed-version # 0.6.0 ``` ### 3.2 Replay a private CAN log @@ -304,7 +345,7 @@ canplayer -I /path/to/private-candump.log vcan0=can0 # add -l i to loop fo ip -statistics link show vcan0 # RX packets should climb ``` -Point SignalK's NMEA 2000/SocketCAN connection at `vcan0`. With +Point Signal K's NMEA 2000/SocketCAN connection at `vcan0`. With `VESSELSTACK_CAN_CREATE_VCAN=true`, the rendered `vesselstack-socketcan.service` loads the helper that creates the virtual interface and brings it up; physical `can*` interfaces must already exist. @@ -345,11 +386,11 @@ values, and record the `doctor` result and data-directory size each day. 1. **Update from the previous version.** On a spare lab install (or before the soak), install the prior tag the normal way (`git checkout v0.3.0` in a clean - clone, `sudo ./install.sh --config ... --start`), then update with 0.5.7 from + clone, `sudo ./install.sh --config ... --start`), then update with 0.6.0 from the unpacked release directory: ```bash - sudo vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.5.7 # [MUTATES HOST] + sudo vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.6.0 # [MUTATES HOST] ``` It runs `install.sh --dry-run`, takes a verified backup into @@ -387,14 +428,14 @@ values, and record the `doctor` result and data-directory size each day. sudo chown -R root:root offline-bundle && sudo chmod -R go-w offline-bundle sudo scripts/prepare-offline-bundle.sh --load /path/to/offline-bundle # docker load sudo VESSELSTACK_OFFLINE=true VESSELSTACK_WHEELHOUSE=/path/to/offline-bundle/wheelhouse \ - vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.5.7 + vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.6.0 ``` The bundle path and **every parent** must be root-owned and not - group/world-writable, or root refuses it. Managed Docker SignalK with + group/world-writable, or root refuses it. Managed Docker Signal K with `SIGNALK_VESSELTWIN_PRELOAD=true` requires a valid preload plugin install prepared while online; for a fresh offline install, set it to `false` or - deliberately pre-provision the plugin state. Native SignalK installation is + deliberately pre-provision the plugin state. Native Signal K installation is not supported offline. `DISPLAYS_ENABLE=true` likewise requires its bridge virtualenv to have been provisioned online first; set it to `false` for a fresh offline install. @@ -415,14 +456,14 @@ Assemble one directory on the connected machine; transfer it as a unit: | File | Source | | --- | --- | -| `vesselstack-0.5.7.tar.gz` | `./build-package.sh` | -| `vesselstack-0.5.7.tar.gz.sha256` | same run | +| `vesselstack-0.6.0.tar.gz` | `./build-package.sh` | +| `vesselstack-0.6.0.tar.gz.sha256` | same run | | `offline-bundle/` (`wheelhouse/`, `images/`, `BUNDLE_INFO`, `MANIFEST.sha256`) | `scripts/prepare-offline-bundle.sh --output ./offline-bundle` | | Release notes with the recorded sha256 + commit | your notes, **kept separately** | The bundle holds no configuration, credentials or vessel data, and `BUNDLE_INFO` pins the release and the lock hash. Rebuild it whenever the release -(or `requirements.lock`) changes. SignalK `native` mode installs from npm and is +(or `requirements.lock`) changes. Signal K `native` mode installs from npm and is **not** covered by the bundle; use `SIGNALK_MODE="docker"` or `existing`. Transfer over the boat LAN from the laptop (SSH keys, no passwords; use the @@ -463,19 +504,19 @@ backup, the recorded sha256 and a console/Ethernet path ready. your notes, not with the `.sha256` file that travelled with it): ```bash - sha256sum ~/carry/vesselstack-0.5.7.tar.gz # must equal the recorded value + sha256sum ~/carry/vesselstack-0.6.0.tar.gz # must equal the recorded value ``` 3. **Stage root-owned [MUTATES HOST].** ```bash sudo install -d -m 0755 /var/lib/vesselstack/releases - sudo tar -xzf ~/carry/vesselstack-0.5.7.tar.gz -C /var/lib/vesselstack/releases --no-same-owner + sudo tar -xzf ~/carry/vesselstack-0.6.0.tar.gz -C /var/lib/vesselstack/releases --no-same-owner sudo cp -a ~/carry/offline-bundle /var/lib/vesselstack/releases/ - sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.5.7 /var/lib/vesselstack/releases/offline-bundle - sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.5.7 /var/lib/vesselstack/releases/offline-bundle - cat /var/lib/vesselstack/releases/vesselstack-0.5.7/COMMIT # matches recorded commit - sudo /var/lib/vesselstack/releases/vesselstack-0.5.7/scripts/prepare-offline-bundle.sh \ + sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-0.6.0 /var/lib/vesselstack/releases/offline-bundle + sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-0.6.0 /var/lib/vesselstack/releases/offline-bundle + cat /var/lib/vesselstack/releases/vesselstack-0.6.0/COMMIT # matches recorded commit + sudo /var/lib/vesselstack/releases/vesselstack-0.6.0/scripts/prepare-offline-bundle.sh \ --verify /var/lib/vesselstack/releases/offline-bundle # [read-only] ``` @@ -484,16 +525,16 @@ backup, the recorded sha256 and a console/Ethernet path ready. boat uses: ```bash - sudo /var/lib/vesselstack/releases/vesselstack-0.5.7/scripts/prepare-offline-bundle.sh \ + sudo /var/lib/vesselstack/releases/vesselstack-0.6.0/scripts/prepare-offline-bundle.sh \ --load /var/lib/vesselstack/releases/offline-bundle # verifies, then docker load sudo VESSELSTACK_OFFLINE=true VESSELSTACK_WHEELHOUSE=/var/lib/vesselstack/releases/offline-bundle/wheelhouse \ - vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.5.7 + vesselstackctl update /var/lib/vesselstack/releases/vesselstack-0.6.0 ``` 5. **Verify.** ```bash - cat /opt/vesselstack/config/installed-version # 0.5.7 + cat /opt/vesselstack/config/installed-version # 0.6.0 sudo vesselstackctl doctor # RESULT healthy sudo reboot # after reconnecting: @@ -562,12 +603,12 @@ secrets. `vesselstack-update --rollback BACKUP_DIRECTORY` is only for code-only ## 7. Acceptance checklist -- [ ] Lab prerelease `v0.5.7` annotated tag is intentionally unsigned; CI asset sha256 and commit recorded. +- [ ] Release `v0.6.0`: tag type (signed or unsigned) and Latest status recorded in the CHANGELOG; CI asset sha256 and commit recorded. - [ ] Lab: 48 h+ soak with daily `doctor` healthy; replay data visible in Grafana. - [ ] Lab: update from 0.3.0, backup/verify/restore, and offline install rehearsed. - [ ] Boat: sha256 of the carried tarball equals the recorded value. - [ ] Boat: verified backup exists on separate media and a copy is off the Pi. -- [ ] Boat: `installed-version` is `0.5.7`; `doctor` healthy before and after reboot. +- [ ] Boat: `installed-version` is `0.6.0`; `doctor` healthy before and after reboot. - [ ] Boat: `can0` RX counter increases, error/dropped counters flat. - [ ] Boat: tokens are per-host; no lab secrets copied; control panel reached by tunnel only. @@ -608,6 +649,6 @@ machine only. images) has not been built and installed on a real Pi; section 3.5.3 is that rehearsal. Image tarballs are manifest-checked but not digest-pinned. - Scoped read-only InfluxDB tokens for Boat Chat and Grafana are **deferred** - (see README and CHANGELOG); they are not part of 0.5.7. + (see README and CHANGELOG); they are not part of 0.6.0. - The tarball is integrity-checked by sha256 only on the boat (no signature check in `install.sh`/`vesselstackctl update`). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5f43f8e..1e92b74 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,9 +34,9 @@ Every change must update README.md as required by AGENTS.md. ## Release checklist -The quick-start URLs (`.../releases/latest/download/get-vesselstack.sh` and -`.../releases/download/vX.Y.Z/get-vesselstack.sh`) only work if the GitHub release -carries exactly these files. Nothing publishes a release for you: the tag-triggered +The quick-start URLs (the pinned `.../releases/download/vX.Y.Z/get-vesselstack.sh` and the +secondary `.../releases/latest/download/get-vesselstack.sh`, which follows whichever release +GitHub marks Latest) only work if the GitHub release carries exactly these files. Nothing publishes a release for you: the tag-triggered workflow only creates a **draft**, and the CI-built draft is the authoritative release. Do not build and upload files by hand. @@ -48,12 +48,13 @@ Do not build and upload files by hand. bootstrap, build-package, Compose config, image manifests, Python, service integration). Merge it to `main`. 3. Tag the merged `main` commit (signed tag, see [DEPLOYMENT.md](DEPLOYMENT.md) - section 1) and push the tag. The workflow refuses a tag whose commit is not on - `main`, or that differs from `VERSION`. The explicitly approved `v0.5.7` - lab-prerelease exception is an annotated unsigned tag because no SSH/GPG key - is available; it is not a stable promotion and must not be treated as signed. - The earlier `v0.5.1` tag remains untouched and was not published because its - release pipeline caught the bridge startup-status race. + section 1; the recommended 1Password-held signing key is the first subsection there) + and push the tag. The workflow refuses a tag whose commit is not on + `main`, or that differs from `VERSION`. The `v0.5.7` lab prerelease was an annotated + unsigned tag because no SSH/GPG key was available; it was not a stable promotion and is not + signed. The `v0.5.1` tag is untouched and was not published because its release pipeline + caught the bridge startup-status race. The signing and Latest status of `v0.6.0` is + recorded in the CHANGELOG at release time. 4. `.github/workflows/release.yml` then (a) reruns the whole CI workflow on the tagged commit, (b) builds with `VESSELSTACK_BUILD_REQUIRE_CLEAN=true` in a read-only job and uploads the files as workflow artifacts, and (c) in a separate @@ -71,9 +72,9 @@ Do not build and upload files by hand. verified, `/etc/apt/sources.list.d/docker.sources` written, `docker-ce` and `docker-compose-plugin` installed, `docker info` answering; check `dpkg --print-architecture` says `arm64`), an upgrade from the previous release, and an offline - upgrade. The test suites use stubs and cannot cover this. For the explicitly - authorized `0.5.7` lab prerelease, publication may precede physical-Pi - acceptance; record that acceptance is pending. Complete the pinned installer + upgrade. The test suites use stubs and cannot cover this. For a prerelease, + publication may precede physical-Pi acceptance; record that acceptance is pending + (it was for `0.5.7`). Complete the pinned installer and component lifecycle recipe in [CONTROL_PANEL_TESTING.md](CONTROL_PANEL_TESTING.md) before any stable promotion. 7. Publish the draft as a **pre-release** (or otherwise not latest) first, so @@ -180,6 +181,84 @@ No scheduler or webhook automatically deploys changes. Git pulls alone cannot activate arbitrary infrastructure edits. Routine application updates are one command; release/schema/dependency changes still need separate review. +## Wizard internals + +Reference for the installer's terminal and browser wizards. Owners normally need only the +[quick start](../README.md#quick-start-one-command-on-the-raspberry-pi). + +### Terminal wizard in the guided installer + +The guided installer asks the configuration questions **in the terminal** (the terminal wizard, +`vesselstack-wizard.py --cli`, the default). It reads your answers from the terminal +(`/dev/tty`, so the piped `curl | sudo bash` form works), validates every answer +immediately with the same rules as the browser wizard (`DEFAULTS`, `validate()`, +`render()`; stricter checks only where `install.sh` enforces the same limits, such as +the boat name characters), and offers sections for the boat (name, type, units, timezone +detected from the system, optional and private MMSI, call sign, length and beam), +Signal K (docker, existing or native; docker recommended), NMEA 2000 / CAN +(`SOCKETCAN_ENABLE`, interface, bit rate and the home-lab virtual-CAN option +`VESSELSTACK_CAN_CREATE_VCAN`), AIS, Home Assistant (URL, optional hidden token), Boat +Chat provider (default `local`) and Telegram, the firewall (untrusted interface, enable, +strict mode, allowed ports), raw-data retention (`INFLUXDB_RAW_RETENTION`; empty means +30 days on a new install and no change to an existing database) and who can reach the +dashboards (`local`, `dashboards` or `all`; see "Reaching VesselStack from other devices"). +Ports, container and bucket names, folders, the service account and the generated +secrets sit behind "Advanced settings? [y/N]". Passwords and tokens default to being +generated during installation, are read without echo when you do type one and are +never printed or logged. It detects the architecture, Docker, `can0` and the network +interface names to inform defaults and hints; it never turns hardware on by itself. +The browser wizard asks the same "Who should be able to reach the dashboards?" question +(`local`, `dashboards` default, `all`) on its network step. The page sends only the +preset name (`LAN_ACCESS`); the server validates it (an unknown name is refused with HTTP 400, +nothing is written) and expands it with the terminal wizard's `LAN_PRESETS` mapping +into `VESSELSTACK_WEB_BIND_ADDRESS`, `VESSELSTACK_DATA_BIND_ADDRESS`, `BOAT_CHAT_HOST` and `HOMEASSISTANT_BIND_ADDRESS`; +the preset wins over explicit bind values, `LAN_ACCESS` is never written to the env file +and the independent Control Panel access choice is preserved. Choosing `all` shows the warning that Prometheus +has no login and recommends the firewall. The loopback, Host/Origin, URL-fragment token +and content-type protections are unchanged. +The terminal wizard has no network surface (no web server, no token, no URL), so it +runs as root directly, in a private root-only `0700` directory; its output is then +treated exactly like the browser wizard's: copied once into the root-only staging +directory, linted, re-validated as root with `vesselstack-wizard.py --validate FILE` +(known settings only, allowed characters, enum and boolean values) and installed +root-only, mode 600. `--config FILE` gets the same treatment (opened without following +symlinks, a regular file of at most 64 KiB, copied once, only the staged copy linted, +validated and used); with `--set` the staged copy is the base the values are applied +to. Optional browser wizard: add `--web-wizard`; it is unchanged (loopback only on a +random free port, `--wizard-port N`, run as an unprivileged user or the temporary +`vesselstack-wizard` system account, a `#token=` address printed once, open on the Pi +or through the printed `ssh -L` tunnel) and cannot be combined with `--yes`-style +configuration options (`--config`, `--set`, `--defaults`). Limitation: the Docker line +in a wizard's preflight reflects what that wizard sees; `install.sh` performs the +authoritative checks. + +### Running the wizards by hand + +The terminal wizard (`--cli`) asks in the terminal and writes `vesselstack.env`; add +`--defaults` and `--set KEY=VALUE` to configure without questions, `--base FILE` to start from an +existing file. For the browser wizard open the address it prints (`http://127.0.0.1:8088/#token=...`), or use the +SSH tunnel documented in the guide and open the same address on your workstation. +The wizard accepts `--port N`, `--output FILE` (where to write the env file) and +`--exit-after-save` (stop after the first successful save; the one-line installer +uses it) and `--validate FILE` (check an existing env file with the wizard's rules, +exit 0 if valid, 1 otherwise, no server started; `VESSELSTACK_CAN_CREATE_VCAN` is a +known wizard setting, default `false`). Nothing is persisted except the temporary account, which is removed on exit (`sudo userdel +vesselstack-wizard` if a run was killed with SIGKILL). It prints a per-run address of the form `http://127.0.0.1:PORT/#token=...`; open +that exact address. The token is carried in the URL fragment (never sent to the +server, never present in any served file); `wizard/app.js` reads it, removes it +from the address bar with `history.replaceState` and sends it as `X-Wizard-Token` +with every API call (`/api/system` and `/api/save` refuse requests without it). +The wizard needs no root privileges; run it as a normal user. Requests are refused unless the `Host` +header is `127.0.0.1`/`localhost`/`[::1]` on the bound port, any `Origin` is the +same loopback origin, the token matches, and saves use `Content-Type: +application/json` (this blocks cross-site form posts and DNS rebinding). The env +file is created mode 600 from the start and never through a symlink. Open the +printed URL itself (including the `#token=` part); a bookmarked older address stops working when the wizard restarts. The dependency-free wizard binds only to loopback, shows relevant host +prerequisites, and writes a complete private `vesselstack.env`. It does not +change boot firmware, firewall rules, marine wiring, or start services. Then run +`sudo ./install.sh --config vesselstack.env --dry-run` and `--start` as described +in the [README](../README.md#5-run-preflight). + ## Developer validation On Linux, keep temporary test roots protected and remove them after the run: @@ -315,7 +394,7 @@ statement about the current lab prerelease. 1. **Complete:** InfluxDB schema names and runtime Boat Chat branding are configurable, with legacy defaults retained only for 0.x upgrades. -2. **Complete:** Existing, pinned native, and pinned Docker SignalK modes are +2. **Complete:** Existing, pinned native, and pinned Docker Signal K modes are included. 3. **Complete:** Opt-in SocketCAN and AIS-catcher modules include hardware probes and stay disabled by default. @@ -347,8 +426,8 @@ refused. This fixes values with spaces (such as `AIS_CATCHER_ARGS`) being passed to Compose as one argument. The installed `vesselstackctl` defaults to the installation's `VESSELSTACK_ROOT`, so a custom root works without exporting it. Boat Chat and Control Panel token checks no longer fail on non-ASCII header -text, `GET`/`HEAD /api/settings` now need the access token (the Settings page -sends the token saved by **Connect**), unauthenticated loopback settings writes +text, `GET`/`HEAD`/`POST /api/settings` now need the access code (the Settings page +sends the code saved by **Connect**; see [Boat Chat setup](BOAT_CHAT.md)), unauthenticated loopback settings writes need a `localhost`/loopback or configured `BOAT_CHAT_HOST` `Host` name (DNS rebinding defense; configure `BOAT_CHAT_SETTINGS_TOKEN` to change settings from other hosts), and power tracking splits intervals at the zero crossing so diff --git a/docs/DISPLAYS.md b/docs/DISPLAYS.md index cf35ea7..2be46fd 100644 --- a/docs/DISPLAYS.md +++ b/docs/DISPLAYS.md @@ -5,7 +5,7 @@ Purpose: reference for the optional ESP32 touch-screen instrument displays: brid [Back to README](../README.md) · [Documentation index](README.md) VesselStack is gaining configurable live-instrument displays: small ESP32 touch -screens that show SignalK values (speed, depth, wind angle, battery voltage and +screens that show Signal K values (speed, depth, wind angle, battery voltage and so on) on pages you define. The first board is the Waveshare ESP32-S3-Touch-LCD-2.8C, planned to run ESPHome with LVGL. The board registry and page layouts are data, so more boards and layouts can be added later. @@ -14,13 +14,13 @@ and page layouts are data, so more boards and layouts can be added later. the MQTT topic/payload and freshness logic, the board registry, the MQTT account/ACL provisioning (`displayctl.py sync-mqtt`), the **device lifecycle commands** (`vesselstackctl display list|add|remove|rotate|wifi|status`), the -**SignalK-to-MQTT bridge service** and the **ESPHome project generator** +**Signal K-to-MQTT bridge service** and the **ESPHome project generator** (`vesselstackctl display render|export|build|flash`, see [Firmware: generate, build and flash](#firmware-generate-build-and-flash)) exist today (`displays/`). With `DISPLAYS_ENABLE=true` the bridge publishes your configured instruments to Mosquitto. The Waveshare 2.8C has been verified on real hardware: bring-up (colours, orientation, round safe area and touch coordinates) needed no knob -changes, and the path SignalK, bridge, MQTT, screen worked end to end on a +changes, and the path Signal K, bridge, MQTT, screen worked end to end on a Raspberry Pi with a firmware built on the Pi and flashed over USB serial. Not yet verified on hardware: OTA updates, long-term stability and `.local` (mDNS) name resolution. Building and flashing on the Pi is optional @@ -52,7 +52,7 @@ environment already exists. ## Displays bridge service `vesselstack-displays-bridge.service` (`displays/bridge.py`) subscribes to the -SignalK websocket stream (`vessels.self` only, only the paths in +Signal K websocket stream (`vessels.self` only, only the paths in `displays.json`), converts and rounds values with the same tested logic as the topic schema below, and publishes them retained to Mosquitto. It is opt-in and adds no listening port. @@ -60,8 +60,8 @@ adds no listening port. Enable it: 1. Set `DISPLAYS_ENABLE=true` in `/opt/vesselstack/config/vesselstack.env` (or - the Displays section of the Control Panel). If SignalK security is on, also - set `DISPLAYS_SIGNALK_TOKEN` to a SignalK access token (read access is + the Displays section of the Control Panel). If Signal K security is on, also + set `DISPLAYS_SIGNALK_TOKEN` to a Signal K access token (read access is enough). 2. Run `sudo ./install.sh --config /opt/vesselstack/config/vesselstack.env --start` (or Control Panel Apply). On the first run the installer seeds @@ -97,7 +97,7 @@ restart loop). Boat Chat's environment is created as before and owned by the service user. The installer also writes `config/displays-bridge.env` (`root:root`, mode 0600; systemd reads it as PID 1 before dropping to the service account, so the service user never needs read access; it contains the bridge MQTT password -and the optional SignalK token), renders and enables the unit, and starts it with +and the optional Signal K token), renders and enables the unit, and starts it with `--start`. A render-only run (Control Panel Apply) runs `systemctl try-restart` on the bridge so a running bridge picks up the rewritten environment; a stopped bridge is only started by `--start` or `vesselstackctl start`. The unit runs as the unprivileged service account with the same @@ -116,14 +116,14 @@ warns, without failing, when `displays.json` defines `ha` items but Behaviour: on start it publishes retained `vesselstack/v1/bridge/status` `online` (and `offline` on a clean stop; Mosquitto publishes the retained last will `offline` if the bridge dies), reports `vesselstack/v1/bridge/signalk` -`connected` or `disconnected`, and reconnects to SignalK with exponential -backoff (1 s up to 30 s with jitter). While SignalK is unreachable the clock +`connected` or `disconnected`, and reconnects to Signal K with exponential +backoff (1 s up to 30 s with jitter). While Signal K is unreachable the clock keeps running, so instruments turn `stale` after their `stale_after_s`; they read `missing` until the first value arrives. Treat `bridge/status` as the authority: if the bridge dies hard, `bridge/signalk` keeps its last retained -value. A single SignalK message the bridge cannot process is logged (at most once a +value. A single Signal K message the bridge cannot process is logged (at most once a minute, with a count of the ones suppressed) and dropped; it does not cost the -SignalK connection. The MQTT client queues at most 1000 messages while the +Signal K connection. The MQTT client queues at most 1000 messages while the broker is down, bounding memory. An invalid `displays.json` or settings (exit status 2) or a missing Python dependency (exit status 3) make the bridge exit with a clear journal message and stay stopped (`RestartPreventExitStatus=2 3`); @@ -160,7 +160,7 @@ Troubleshooting: (`curl -fsS "$SIGNALK_URL/signalk"`); an HTTP 401 in the log means set `DISPLAYS_SIGNALK_TOKEN`. - Every instrument is `stale` or `missing` while the bridge is online and - connected: the SignalK `path` in `displays.json` is not being produced, or the + connected: the Signal K `path` in `displays.json` is not being produced, or the token cannot read it. - `bridge/homeassistant` is `disabled` and Home Assistant items show `--`: `DISPLAYS_HA_TOKEN` is empty or Home Assistant rejected it; see @@ -205,7 +205,7 @@ sudo vesselstackctl display remove helm --yes | `build ID` | Optional. Renders, then compiles the firmware on this machine in a one-shot container of the pinned ESPHome image and stores it with a manifest; see [Building and flashing on the Pi](#building-and-flashing-on-the-pi). Root. | | `flash ID (--serial DEVICE \| --ota HOST) [--previous] [--force]` | Optional. Uploads the stored firmware (never compiles) over USB serial or Wi-Fi; `--previous` rolls back; refuses firmware built with credentials that are no longer current unless `--force`. Root. | | `wifi [--open] [--clear]` | Reads the SSID and password from the terminal (password hidden) or, when stdin is not a terminal, from two lines on stdin (for automation); they are never accepted as command-line arguments. SSID: 1-32 bytes of printable UTF-8. Password: 8-63 printable ASCII characters; an empty password (open network) is accepted only with `--open`. Stored once for all displays as `"wifi": {"ssid", "password"}` in `displays.secrets.json` (mode 0600, inside `config/`, so **included in `vesselstackctl backup`**; keep archives private). `--clear` removes it and also deletes `secrets.yaml` from every already rendered tree of every display id under `build/displays/` (`ID/project`, `ID/project.new`, `ID/project.prev`, an old-layout `ID/secrets.yaml`, and the old siblings `ID.new/` and `ID.prev/`), each under that display's build lock without following symlinks (a display whose build is running is skipped with a warning); it does this even when no Wi-Fi credentials were stored, so a rerun after a failed or partial clear still cleans up; a changed password prints a notice instead. In both cases projects rendered and firmware built earlier still contain the old Wi-Fi credentials until `render`/`build` runs again (the firmware cannot be scrubbed; `flash` refuses it without `--force`). Nothing is echoed. | -| `status` | Reads the retained topics `vesselstack/v1/bridge/status`, `vesselstack/v1/bridge/signalk` and `vesselstack/v1/displays/vsdisp-ID/status` through the running Mosquitto container and prints the bridge state, the SignalK link state and each display's last retained state (`online`/`offline` as published by the bridge or device; `unknown`/`never seen` means no retained message exists, so the bridge or device has not connected since the broker's data was last cleared). It exits 1 if the broker container is not running. | +| `status` | Reads the retained topics `vesselstack/v1/bridge/status`, `vesselstack/v1/bridge/signalk` and `vesselstack/v1/displays/vsdisp-ID/status` through the running Mosquitto container and prints the bridge state, the Signal K link state and each display's last retained state (`online`/`offline` as published by the bridge or device; `unknown`/`never seen` means no retained message exists, so the bridge or device has not connected since the broker's data was last cleared). It exits 1 if the broker container is not running. | Behaviour that applies to the changing commands (`add`, `remove`, `rotate`, `wifi`): @@ -550,7 +550,7 @@ It is JSON with `schema` (must be `1`), `mqtt_host` (hostname or IP literal), `ha` (see [Home Assistant items](#home-assistant-items)), optional `night_schedule` and `displays`: -- An instrument (`^[a-z][a-z0-9_]{0,31}$`) has a SignalK `path` for +- An instrument (`^[a-z][a-z0-9_]{0,31}$`) has a Signal K `path` for `vessels.self` (numeric scalar values only), `label` (1-16 characters), `convert` (one named conversion), `unit` (up to 8 characters), `precision` (0-3), `stale_after_s` (1-3600 and at least 2 x `heartbeat_s`) and optional @@ -594,7 +594,7 @@ usage, file or JSON parse errors. An instrument may carry optional `thresholds` so its tile changes colour with the value. All numbers are in **display units** (after `convert`, for example -knots or feet, not SignalK SI units) and must be finite JSON numbers (booleans +knots or feet, not Signal K SI units) and must be finite JSON numbers (booleans are rejected): ```json @@ -647,7 +647,7 @@ flash the previous firmware (`display flash ID --previous ...`). ### Notifications -A SignalK notification (an alarm or warning such as the anchor alarm or a low +A Signal K notification (an alarm or warning such as the anchor alarm or a low battery notification) can be shown as a coloured status cell on a `status-grid` page. Add a top-level `notifications` object to `displays.json`: @@ -659,7 +659,7 @@ page. Add a top-level `notifications` object to `displays.json`: } ``` -- `path` must start with `notifications.` (own vessel, SignalK path syntax), +- `path` must start with `notifications.` (own vessel, Signal K path syntax), `label` is 1-16 printable characters in the bundled font (checked like instrument labels), `absent` is `ok` (default) or `unknown` and `alarm` is a boolean (default true) that decides whether the notification counts in the @@ -671,24 +671,24 @@ page. Add a top-level `notifications` object to `displays.json`: notification (value `null`) follows `absent`. Any other state string, or a value that is not such an object, is `unknown` and shows grey `--`. The notification `message` text is never read, published or shown. -- `absent`: SignalK only reports a notification while it exists, so silence +- `absent`: Signal K only reports a notification while it exists, so silence means either "nothing is wrong" or "the source is not running". `absent: "ok"` treats a notification that has not been raised, or has been cleared, as - healthy (right for alarms SignalK itself generates, such as the anchor alarm); + healthy (right for alarms Signal K itself generates, such as the anchor alarm); `absent: "unknown"` shows grey `--` instead (right when a missing notification could just as well mean the monitoring plugin is not running). Either way, - while SignalK is disconnected every notification is `stale` and grey, and + while Signal K is disconnected every notification is `stale` and grey, and nothing is ever green on a stale or unknown state. -- After SignalK (re)connects, notifications that nobody has reported on stay +- After Signal K (re)connects, notifications that nobody has reported on stay `unknown` for 3 s so that an alarm that is still active is shown red as soon - as SignalK re-sends it instead of flashing green first. + as Signal K re-sends it instead of flashing green first. Topic: each notification is published retained to `vesselstack/v1/notifications/` as compact JSON, `{"v":null,"u":"","st":"ok","sev":"alarm","ts":"2026-01-01T00:00:00.000Z","age":0.4}` (`v` is always `null` and `u` empty; `sev` is always present). `st` is `ok` -while SignalK is connected, `stale` once it disconnects (everything heard is -forgotten and SignalK re-sends active notifications on reconnect) and `missing` +while Signal K is connected, `stale` once it disconnects (everything heard is +forgotten and Signal K re-sends active notifications on reconnect) and `missing` before the first connection. Items are republished every `heartbeat_s`; the display blanks a cell (grey `--`) after 3 x `heartbeat_s` without a message, when the bridge is offline, or when MQTT is disconnected. The bridge subscribes @@ -711,7 +711,7 @@ screen with a flashing banner. true. A stale or never-heard item counts as `unknown`. - `na`, `nw`, `nu` are the numbers of items that are `alarm`, `warn` and `unknown`. `max` is the highest of `alarm` > `warn` > `unknown` > `ok`: - `unknown` ranks **above** `ok`, so a dead SignalK feed or stale sensor is never + `unknown` ranks **above** `ok`, so a dead Signal K feed or stale sensor is never reported as all clear. With no counted item at all the topic is not published. - `ids` lists the alarm ids first, then the warn ids, each group in @@ -819,14 +819,14 @@ three 10 px dots, the page title (the `!literal` page `title`, drawn in the smal font, up to 200 px wide) sits in a second row at y 40, and one 8 px dot per page with the current page highlighted sits at y 414, below the 340 x 340 content area (y 70-410). Nothing overlaps the content area or the alarm banner. The -dots, left to right, are SignalK (bridge reports `connected`), the bridge +dots, left to right, are Signal K (bridge reports `connected`), the bridge (`bridge/status` `online`) and MQTT (the display's own connection): green is -good, red is bad and grey is unknown. The SignalK dot is retained data, so it is +good, red is bad and grey is unknown. The Signal K dot is retained data, so it is only ever green while the bridge is online and MQTT is connected; a stopped bridge shows grey, never a stale green. When `displays.json` has any `ha` item a fourth dot (Home Assistant, 14 px to the right of the MQTT dot) follows the retained `bridge/homeassistant`: green `connected`, red `disconnected`, grey `disabled` or not -yet known; like the SignalK dot it is only coloured while the bridge is online. Without +yet known; like the Signal K dot it is only coloured while the bridge is online. Without `ha` items nothing is generated for it. Page titles are checked against the bundled font like labels (`validate` and `display render` report a glyph the font lacks), but only when `header` is on. @@ -974,12 +974,12 @@ use single`. arcs and indicator colours switch to the red/amber night palette through the same guarded repaint as the tiles (no blue; the neutral colour becomes dim amber). -- **SignalK tank paths.** SignalK publishes tanks as +- **Signal K tank paths.** Signal K publishes tanks as `tanks...currentLevel`, a **ratio from 0 to 1**, for example `tanks.fuel.0.currentLevel`, `tanks.freshWater.0.currentLevel`, `tanks.blackWater.0.currentLevel` (also `wasteWater`, `lubrication`, `liveWell`, `baitWell`, `ballast`, `gas`). Use `"convert": "ratio->%"`, `"unit": "%"`, - `"min": 0`, `"max": 100`. To find the paths on your boat open the SignalK + `"min": 0`, `"max": 100`. To find the paths on your boat open the Signal K admin UI (Data Browser, filter `tanks`) or read `http://:3000/signalk/v1/api/vessels/self/tanks`; the `` is whatever your tank sensor plugin or N2K gateway reports (`0`, `1`, ... or a name). If @@ -1027,12 +1027,12 @@ want to see the solar line). The layout is round only (`electrical needs a round board; use grid-2x2` on a rectangular board). It is never chosen as the default page of `display add`. -- **SignalK paths.** `electrical.batteries..voltage` (volts, `convert` +- **Signal K paths.** `electrical.batteries..voltage` (volts, `convert` `none`, `unit` `V`, `precision` 2), `electrical.batteries..capacity.stateOfCharge` (a **ratio 0 to 1**: `convert` `ratio->%`, `unit` `%`, `min` 0, `max` 100) and `electrical.batteries..current` (amperes, `none`, `unit` `A`, `precision` 1). `` is what your battery monitor plugin or N2K gateway reports (`house`, - `0`, ...); check the Data Browser in the SignalK admin UI (filter + `0`, ...); check the Data Browser in the Signal K admin UI (filter `electrical`). Solar controllers usually publish `electrical.solar..current`. For a shore pill see below. - **SoC ring.** The ring is the `gauge-arc` ring of the SoC instrument: the @@ -1048,7 +1048,7 @@ default page of `display add`. and the header and page-dot areas. The voltage uses the 56 px digits (the font `grid-2x2` uses; a display with an `electrical` page gets it). Fonts, glyph sets and the other layouts are otherwise unchanged. Estimated from font sizes, not measured on the panel. -- **Current and sign convention.** SignalK reports the battery current as +- **Current and sign convention.** Signal K reports the battery current as positive while **charging** and negative while discharging; the value is shown with a minus sign when negative. The word next to it is `CHG` above +0.05 A, `DSG` below -0.05 A and empty inside that band (and when stale). It is built @@ -1058,7 +1058,7 @@ default page of `display add`. so the number, `CHG`/`DSG`, any `thresholds` and `alarm` all use the corrected sign. `negate` works on any instrument. - **Shore pill.** With a **notification** (for example - `notifications.electrical.shorePower`, which you create with a SignalK rule or + `notifications.electrical.shorePower`, which you create with a Signal K rule or plugin; the path in the example is a placeholder) the pill is coloured by severity and shows `OK`, `WARN`, `ALARM` or `--` exactly like a `status-grid` cell; use `"absent": "unknown"` unless a missing notification really means @@ -1096,10 +1096,10 @@ row spacing and the 56 px digits against the ring. ### Home Assistant items Home Assistant (HA) entities, such as a bilge pump switch, a high water sensor, the -shore power state or a summary sensor you built in HA, can be shown next to SignalK +shore power state or a summary sensor you built in HA, can be shown next to Signal K data. The bridge keeps one websocket open to HA, reads only the entities named in `ha`, and publishes a severity and a small index per item, never the state text. This -is an optional extra: SignalK instruments and notifications work whether or not HA is +is an optional extra: Signal K instruments and notifications work whether or not HA is reachable. It has been verified against a fake HA server in the integration tests, not yet against a real Home Assistant. @@ -1126,11 +1126,11 @@ Then set it, with the URL if HA is not on the Pi: `https://`, or keep HA on the Pi. `wss://` verifies the certificate against the system trust store; there is no custom-CA option. For a self-signed HA, point the bridge at HA on loopback or add the CA to the Pi's system store. -- **Redirects are refused.** The HA (and SignalK) websocket upgrade does not follow HTTP +- **Redirects are refused.** The HA (and Signal K) websocket upgrade does not follow HTTP redirects (websockets 17.3 would follow them, even to another host, which would hand the token to that host); point `HOME_ASSISTANT_URL` at the final address. - Re-run the installer (or Control Panel Apply). The token is written only to the - root-only 0600 `config/displays-bridge.env` (included in backups, like the SignalK + root-only 0600 `config/displays-bridge.env` (included in backups, like the Signal K token) and sent once in the websocket `auth` message; the bridge never logs it, and removes it from any log text. With no token the bridge logs one warning, does not connect, and the items show `--`. @@ -1301,7 +1301,7 @@ point): ### Board registry schema 2 -`displays/boards/registry.json` is now `schema` 2, where each layout is an +`displays/boards/registry.json` uses `schema` 2, where each layout is an object `{"capacity", "min_items", "shapes": ["round","rect"], "types": ["instrument","notification","ha"]}` (the allowed item types). `schema` 1 registries (a layout is just an integer capacity) are still accepted and mean @@ -1324,8 +1324,8 @@ layout that takes only instruments for the default page (`tank-bars`, Values are published retained to `vesselstack/v1/instruments/` as compact JSON: `{"v":6.4,"u":"kn","st":"ok","ts":"2026-01-01T00:00:00.000Z","age":0.4}`. `v` is converted, then rounded to `precision`, and is `null` unless `st` is -`ok`. `ts` is the SignalK timestamp (or `null`); `age` is seconds since the -bridge received the value, measured on the bridge clock, not the SignalK +`ok`. `ts` is the Signal K timestamp (or `null`); `age` is seconds since the +bridge received the value, measured on the bridge clock, not the Signal K timestamp. Other reserved topics are `vesselstack/v1/bridge/status`, `vesselstack/v1/bridge/signalk`, `vesselstack/v1/bridge/clock` (see [Touch, header, night mode and dimming](#touch-header-night-mode-and-dimming)) and @@ -1350,20 +1350,20 @@ Notifications use their own retained topics, `vesselstack/v1/notifications/` - `missing`: no value has been received since the bridge started. - A changed value is published at most once per `min_interval_ms`; unchanged values are not republished except by the `heartbeat_s` heartbeat, which also - refreshes `age`. Null, non-numeric, non-finite and boolean SignalK values are + refreshes `age`. Null, non-numeric, non-finite and boolean Signal K values are ignored, as are values whose magnitude exceeds 1e15 after conversion. ## Supported boards | Board | Screen | Layouts | Status | | --- | --- | --- | --- | -| Waveshare ESP32-S3-Touch-LCD-2.8C (`waveshare-esp32-s3-touch-lcd-2.8c`) | 480x480 round, 70 px safe inset, 340x340 content area, header and footer bands (`ring`) | `single`, `grid-2x2`, `status-grid`, `tank-bars`, `gauge-arc`, `electrical` | Config validated and compiles with ESPHome 2026.10.0 (`esphome config` in CI); builds on a laptop or, optionally, on the Pi (`display build`, `display flash`; verified on real hardware: colours, orientation, safe area, touch and SignalK to screen end to end after `display build` and `display flash --serial`; OTA, long-term stability and `.local` resolution not yet verified; see the [bring-up checklist](#hardware-bring-up-checklist-waveshare-esp32-s3-touch-lcd-28c)) | +| Waveshare ESP32-S3-Touch-LCD-2.8C (`waveshare-esp32-s3-touch-lcd-2.8c`) | 480x480 round, 70 px safe inset, 340x340 content area, header and footer bands (`ring`) | `single`, `grid-2x2`, `status-grid`, `tank-bars`, `gauge-arc`, `electrical` | Config validated and compiles with ESPHome 2026.10.0 (`esphome config` in CI); builds on a laptop or, optionally, on the Pi (`display build`, `display flash`; verified on real hardware: colours, orientation, safe area, touch and Signal K to screen end to end after `display build` and `display flash --serial`; OTA, long-term stability and `.local` resolution not yet verified; see the [bring-up checklist](#hardware-bring-up-checklist-waveshare-esp32-s3-touch-lcd-28c)) | `list-6` is defined but not offered on the round board: `validate_registry` rejects any board whose `layouts` lists a layout the firmware generator cannot render (`schema.SUPPORTED_LAYOUTS`, shared with `render.py`). **Adding a board versus adding a layout.** A new board is data only: an entry in `displays/boards/registry.json` that lists already supported layouts and its ESPHome package. A new layout is code: teach `displays/render.py` to draw it and add its id to `SUPPORTED_LAYOUTS` in `displays/schema.py`, otherwise the registry is refused. -**Rounding.** Instrument and Home Assistant numeric values use the same rounding (half away from zero, never `-0.0`, an integer at `precision` 0, a value beyond 1e15 is treated as missing). A heading (`rad->deg_360`) that rounds up to 360 is shown as 0. A numeric Home Assistant item at `precision` 0 is now published as an integer (`3`, not `3.0`) in the MQTT payload; rollback is `git revert`. +**Rounding.** Instrument and Home Assistant numeric values use the same rounding (half away from zero, never `-0.0`, an integer at `precision` 0, a value beyond 1e15 is treated as missing). A heading (`rad->deg_360`) that rounds up to 360 is shown as 0. A numeric Home Assistant item at `precision` 0 is published as an integer (`3`, not `3.0`) in the MQTT payload. ## Planned limitations @@ -1372,8 +1372,8 @@ Notifications use their own retained topics, `vesselstack/v1/notifications/` network. - With the VesselStack firewall enabled, displays must be on a trusted interface that can reach the Raspberry Pi's MQTT port. -- The build path is exporting from the Pi and compiling on a laptop; building - and flashing on the Pi (a Compose profile) is planned and not available yet. +- Building on a laptop is the primary documented path; building and flashing on the + Pi (`display build`, `display flash`) is optional. Only the `single`, `grid-2x2`, `status-grid`, `tank-bars`, `gauge-arc` and `electrical` layouts are generated. - The runtime `displays.json`, `displays.secrets.json`, ESPHome `secrets.yaml`, `.esphome/` and `displays/build/` output, `$VESSELSTACK_ROOT/build/` and firmware diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 8128269..5c0a780 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -41,13 +41,13 @@ installer. `-t` gives the session a terminal so the installer can ask questions: ```bash ssh -t YOUR_USER@raspberrypi.local -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` -The `latest/download` URL follows the latest stable release. The lab prerelease -`v0.5.7` is not selected by that URL; use its explicit pinned URL or -`--version 0.5.7` only when following the lab and offline-release instructions -in [DEPLOYMENT.md](DEPLOYMENT.md). +The URL pins release `v0.6.0`, so it installs that release whatever GitHub marks as Latest +(`--version 0.6.0` selects the same release). The form `.../releases/latest/download/get-vesselstack.sh` +points to whichever release GitHub marks Latest, which is not necessarily the newest one. For a +lab Pi and an offline boat, follow [DEPLOYMENT.md](DEPLOYMENT.md). The script checks the host first. It asks before installing missing Debian packages or Docker Engine and Compose v2 from Docker's official apt repository, @@ -64,7 +64,8 @@ provider credentials. ![Example VesselStack preflight review](images/wizard-preflight.png) -*Example of a preflight review; your host checks and values may differ.* +*The optional browser wizard's preflight page, as an example. The terminal questions are the +default path, and the wording, host checks and values may differ.* ## How to access it @@ -89,9 +90,11 @@ ssh -L 8780:127.0.0.1:8780 YOUR_USER@PI Then open `http://127.0.0.1:8780` and enter the token. Do not expose port 8780 directly to the internet. Exact ports, LAN presets, firewall behavior, token storage, and -rollback are documented in the [main README reference](../README.md#quick-start-one-command-on-the-raspberry-pi). +rollback are documented in [Network access and security](NETWORK.md) and the +[Control Panel guide](CONTROL_PANEL.md). -For a deliberate trusted-LAN setup, preview and apply only the panel listener: +The SSH tunnel is the recommended default. Direct panel access from the boat network is an +opt-in choice: for a deliberate trusted-LAN setup, preview and apply only the panel listener: `sudo vesselstackctl panel-access lan --dry-run`, then `sudo vesselstackctl panel-access lan`. This is unencrypted HTTP on all interfaces; a strict firewall may still block it. Roll back with @@ -108,7 +111,7 @@ the offline installer cannot fetch them. Before leaving the lab, verify that the matching offline bundle contains every required Python dependency and container image. -Follow [DEPLOYMENT.md](DEPLOYMENT.md) for the signed release workflow, offline +Follow [DEPLOYMENT.md](DEPLOYMENT.md) for the pinned release workflow, offline bundle verification, dock-side update, and rollback. Internet is not required for the local Signal K/history/dashboard stack once its images and packages are present. It may still be required by an explicitly selected remote Boat @@ -119,7 +122,7 @@ For a fresh offline managed Docker install, the default installed while online. If the plugin is absent, set `SIGNALK_VESSELTWIN_PRELOAD=false` in the carried configuration before installing offline; you can preload it later while online, and enabling, -pairing, and readings remain separate owner choices. Native SignalK is +pairing, and readings remain separate owner choices. Native Signal K is unsupported offline. If `DISPLAYS_ENABLE=true`, provision the displays bridge virtual environment on that Pi while it is online, or set `DISPLAYS_ENABLE=false`. See the @@ -135,7 +138,7 @@ publish vessel positions, MMSIs, call signs, device identifiers, telemetry databases, backups, logs, or environment files. An optional VesselTwin connection uses the `signalk-vesseltwin` plugin, pairing, -and explicit reading opt-ins. Managed Docker/native SignalK installs preload the +and explicit reading opt-ins. Managed Docker/native Signal K installs preload the pinned plugin package by default; that preload does not enable the plugin, pair the account, or authorize readings. Consult the [VesselTwin integration guide](VESSELTWIN.md) @@ -144,11 +147,12 @@ logbook records. ## If something goes wrong -Start with the read-only checks in [Troubleshooting](README.md#troubleshooting): +Start with the read-only checks in [Troubleshooting](../README.md#troubleshooting): `sudo vesselstackctl status`, `sudo vesselstackctl doctor`, and `sudo vesselstackctl urls`, then inspect the relevant service logs. Stop before changing CAN or boot settings if `can0` is missing. For release failures, use the deployment backup and rollback path; do not delete `/opt/vesselstack-data` while investigating. For deeper manual setup, every variable, port, service, security note, and -rollback command remains in the [full README reference](../README.md#quick-start-one-command-on-the-raspberry-pi). +rollback command remains in the [full README reference](../README.md#4-configure-the-vessel) and +[Network access and security](NETWORK.md). diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9e0d76a..39db0f5 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -63,7 +63,7 @@ terminal. The Pi needs no screen, keyboard, or browser, and nothing has to be opened or tunnelled: ```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash ``` The terminal questions come in short sections (boat, Signal K, NMEA 2000 / CAN, @@ -72,13 +72,14 @@ Each has a plain-language explanation and a default in `[brackets]`: Enter accepts it, `?` explains, and `b` goes back one question. A review screen (never showing secrets) lets you save, edit a section, or quit. Passwords and tokens are generated for you; MMSI, call sign and boat size are optional and -stay on the Pi. A short example: +stay on the Pi. Units offers 1 US customary (feet, degrees F, psi, US gallons; the default) and +2 metric; speeds stay in knots and distances in nautical miles either way. A short example: ```text [1/9] Your boat Boat name [My Boat]: Sea Wolf Boat type [Motor vessel]: - Units - choose 1-3 [metric]: 3 + Units - choose 1-2 [us_customary]: 2 Timezone [Europe/Oslo]: Add optional vessel details (MMSI, call sign, length, beam)? [y/N]: ... @@ -102,7 +103,7 @@ non-empty panel token, and restarts only the control-panel service. **Without questions** (scripts, repeatable installs, or when no terminal is available): ```bash -curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh \ +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh \ | sudo bash -s -- --yes --set BOAT_NAME=Foo --set SOCKETCAN_ENABLE=true ``` @@ -114,9 +115,11 @@ not upgrade an installed system or remove Docker conflicts). `--config FILE` use installer stops before changing anything and lists these options. `--web-wizard` is the optional browser wizard (loopback only; needs a browser on the Pi or an `ssh -L` tunnel). -The `latest` URL works only once a release carrying `get-vesselstack.sh` is marked latest. -Pinned to one release (works for any published release, including a pre-release): `.../releases/download/v0.5.7/get-vesselstack.sh`, or add -`--version 0.5.7` (`| sudo bash -s -- --version 0.5.7`). It installs the Debian +The pinned URL above works for any published release, including a prerelease; add +`--version 0.6.0` (`| sudo bash -s -- --version 0.6.0`) or set `VESSELSTACK_VERSION=0.6.0` for +the same effect. The form `https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh` +points to whichever release GitHub marks Latest, which is not necessarily the newest release +(every 0.5.x release was a prerelease), so it is a secondary option. The script installs the Debian prerequisites, asks before installing Docker Engine and Compose v2 from Docker's official apt repository (see the box above), downloads and checksum-verifies the release, asks the configuration questions in the terminal, then runs the installer dry run, the @@ -132,7 +135,9 @@ page, or pass `--sha256 HASH`. To read the script first (recommended for the fir install): ```bash -curl -fsSLO https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh +curl -fsSLO https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh.sha256 +sha256sum -c get-vesselstack.sh.sha256 less get-vesselstack.sh sudo bash get-vesselstack.sh ``` @@ -239,7 +244,7 @@ Skip sections 5 and 6 if you used the one-line installer. ```bash git clone https://github.com/jclima/VesselStack.git cd VesselStack -git checkout v0.5.7 +git checkout v0.6.0 python3 vesselstack-wizard.py --cli # questions in the terminal, works over plain SSH # python3 vesselstack-wizard.py # the optional browser wizard (below) ``` @@ -256,9 +261,11 @@ Then open the same printed address (with its `#token=...`) locally. The assistan walks through the BOM and deployment choices, and writes `vesselstack.env` with mode 600. Stop it with Ctrl-C when finished. -![Wizard system preflight](images/wizard-preflight.png) +![Optional browser wizard: system preflight](images/wizard-preflight.png) -![Wizard NMEA configuration](images/wizard-nmea.png) +![Optional browser wizard: NMEA configuration](images/wizard-nmea.png) + +*Both screenshots show the optional browser wizard (`--web-wizard`). The terminal questions are the default path, and wording may differ.* ## 6. Preflight, install, and start @@ -320,7 +327,7 @@ which has no login). Unattended: `--set LAN_ACCESS=dashboards`. Change it later LAN presets leave the Control Panel setting unchanged; it remains loopback by default unless you explicitly use `vesselstackctl panel-access lan`. `local` also limits Home Assistant (8123) to the Pi through a delimited `http:` block the installer manages in its `configuration.yaml` (it never -touches an `http:` section you wrote yourself and prints the manual step instead). SignalK (3000) +touches an `http:` section you wrote yourself and prints the manual step instead). Signal K (3000) has no bind-address setting, so no preset restricts it; only the firewall's strict mode can (see [Network access and security](NETWORK.md)). @@ -344,16 +351,16 @@ which never built the index, or when the build failed. Fix it with doctor`. 0.4.3 and later build the index during installation, on upgrade and on `vesselstackctl start`. Details: [Boat Chat setup](BOAT_CHAT.md), "Memory index". -## Managed SignalK and VesselTwin preload +## Managed Signal K and VesselTwin preload -Managed Docker/native SignalK preloads pinned `signalk-vesseltwin` 0.2.3 when +Managed Docker/native Signal K preloads pinned `signalk-vesseltwin` 0.2.3 when `SIGNALK_VESSELTWIN_PRELOAD=true`. Preload, enablement, pairing, and reading permissions are separate steps; see [VESSELTWIN.md](VESSELTWIN.md). Offline Docker installs reuse a valid existing plugin and fail before mutation when it -is missing; native SignalK is unsupported offline. Setting the flag false skips +is missing; native Signal K is unsupported offline. Setting the flag false skips future preloads and never removes an existing plugin. -## Troubleshooting: SignalK not running / EACCES on package.json +## Troubleshooting: Signal K not running / EACCES on package.json Prometheus metrics live in `/prometheus` (owned by uid/gid 65534, created by the installer, included in backups) and are kept for `PROMETHEUS_RETENTION` (default `30d`). @@ -365,13 +372,13 @@ With `SIGNALK_MODE=docker`, if the `vesselstack-signalk` container keeps restart and `sudo docker logs vesselstack-signalk` shows `EACCES: permission denied, open '/home/node/.signalk/package.json'`, the data directory `/signalk` is owned by root instead of uid/gid 1000 (the `node` -user in the SignalK image). Release 0.4.3 and later create and repair it on every +user in the Signal K image). Release 0.4.3 and later create and repair it on every installer run (an empty or `root:root` directory only; a populated directory with symlinks, hard links, special files or another owner is left untouched with instructions), and `sudo vesselstackctl doctor` reports it. Start with the read-only doctor output -and inspect the exact configured SignalK data directory and ownership. Resolve +and inspect the exact configured Signal K data directory and ownership. Resolve the indicated cause, then rerun the supported installer repair. Do not -blanket-chown the SignalK tree or restart the container while ownership and +blanket-chown the Signal K tree or restart the container while ownership and symlink state are still unknown. ## Rollback and recovery diff --git a/docs/MQTT.md b/docs/MQTT.md index eaaf0fa..31bd00b 100644 --- a/docs/MQTT.md +++ b/docs/MQTT.md @@ -59,7 +59,7 @@ cannot interleave with a concurrent `displayctl add`. With `DISPLAYS_ENABLE=false` (the default) only the administrator account (`MQTT_USERNAME`) exists: it has `readwrite #` and `read $SYS/#`, so Home -Assistant, SignalK and other existing clients that use that account keep +Assistant, Signal K and other existing clients that use that account keep working. With `DISPLAYS_ENABLE=true` these accounts are added: | Account | Read | Write | @@ -110,7 +110,7 @@ Operational notes: broker without a restart. - `install.sh --dry-run` (the Control Panel "Preflight", and the check that `vesselstackctl update` runs before it takes a backup) never writes files, - pulls images, takes the lock or signals containers, but it now runs + pulls images, takes the lock or signals containers, but it runs `displayctl.py preflight`, which rejects up front everything the sync would reject: an administrator `MQTT_PASSWORD` containing `:` or leading/trailing whitespace (a generated one is always fine), invalid account names, a bridge @@ -188,7 +188,7 @@ Operational notes: survive the new render plus `docker restart`, and only the container recreated from the real `compose.yml` mounts enforces the ACL. - `tests/test-displays-envfile-systemd.sh` boots systemd in a privileged - Debian container and checks that a SignalK token containing dollar, backtick, quote, backslash, + Debian container and checks that a Signal K token containing dollar, backtick, quote, backslash, hash, semicolon and percent characters and spaces reaches the service environment unchanged through `EnvironmentFile=` (systemd does not expand `$` or `%` there), and that `systemd-analyze verify` accepts the rendered unit. If the container cannot diff --git a/docs/NETWORK.md b/docs/NETWORK.md index 1c7d3ca..73fb09a 100644 --- a/docs/NETWORK.md +++ b/docs/NETWORK.md @@ -25,7 +25,7 @@ unchanged. `all` also opens Prometheus, which has **no login**, plus InfluxDB (token) and MQTT (password) to every device on the network; turn the firewall on and prefer Ethernet or a VPN as the way in. -**What is enforced and what is not (host-networked containers).** SignalK (3000) and Home +**What is enforced and what is not (host-networked containers).** Signal K (3000) and Home Assistant (8123) run with `network_mode: host`, so Compose port bindings cannot restrict them. - **Home Assistant: enforced by `local`.** Home Assistant binds the address in @@ -59,7 +59,7 @@ Assistant (8123) run with `network_mode: host`, so Compose port bindings cannot discovery or HomeKit integrations you add) are outside it. Boat Chat, the telemetry indexer and the installer already reach it on `http://127.0.0.1:8123`, and Home Assistant reaches MQTT and InfluxDB on loopback, so nothing else depends on the LAN address. -- **SignalK: NOT restricted by any preset (documented limitation).** Verified against the +- **Signal K: NOT restricted by any preset (documented limitation).** Verified against the pinned image (`signalk-server` v2.27.0): the server starts its HTTP/WebSocket listener with `server.listen(port)` and logs `signalk-server running at 0.0.0.0:3000`; there is no environment variable, `settings.json` key or command-line option for the bind address (only @@ -67,7 +67,7 @@ Assistant (8123) run with `network_mode: host`, so Compose port bindings cannot restrict the port but breaks SocketCAN and local NMEA feeds, so it is not done. The only VesselStack control is the firewall's strict mode (`VESSELSTACK_FIREWALL_STRICT=true`), which drops port 3000 on the untrusted interface only; it is not enabled by a preset. On - a trusted network, or with the firewall off, SignalK stays reachable by other devices + a trusted network, or with the firewall off, Signal K stays reachable by other devices even under `local`. `sudo vesselstackctl urls` shows how every service really listens. ```bash @@ -110,7 +110,7 @@ curl -sS -m 5 -o /dev/null -w 'influx %{http_code}\n' http://PI:8086/health || e ## Network security -Compose now publishes InfluxDB `8086`, Prometheus `9090`, and MQTT `1883` on +Compose publishes InfluxDB `8086`, Prometheus `9090`, and MQTT `1883` on `127.0.0.1` by default (`VESSELSTACK_DATA_BIND_ADDRESS`). Grafana `43000` and Heimdall `80/443` remain reachable on every interface by default (`VESSELSTACK_WEB_BIND_ADDRESS=0.0.0.0`) because the documented dashboards are @@ -121,7 +121,7 @@ Assistant reaches MQTT on loopback. AIS ports are unchanged. At minimum: - restrict Grafana `43000` and Heimdall to trusted management networks, and set `VESSELSTACK_DATA_BIND_ADDRESS` to a trusted LAN/VPN IPv4 address only if external devices must publish to MQTT or InfluxDB; -- keep Home Assistant, SignalK, and Boat Chat off the public internet unless +- keep Home Assistant, Signal K, and Boat Chat off the public internet unless protected by an authenticated reverse proxy; - keep the root-privileged Control Panel loopback-only or behind a trusted VPN and authenticated proxy; @@ -140,7 +140,7 @@ VESSELSTACK_UNTRUSTED_INTERFACE="wlan0" Preflight requires that interface to exist. The policy blocks MQTT, InfluxDB, Prometheus, and Grafana administrative/data ports arriving on that interface, -including Docker-forwarded traffic, while leaving Home Assistant, SignalK, +including Docker-forwarded traffic, while leaving Home Assistant, Signal K, Boat Chat, AIS viewer, HTTP/HTTPS, loopback, VPN, and outbound traffic intact. Review the policy without root or any change using `vesselstack-firewall print` (renders the exact `iptables-restore` payloads). @@ -163,7 +163,7 @@ VESSELSTACK_FIREWALL_ALLOW_UDP="" On the untrusted interface strict mode drops everything except replies to connections the boat started, ICMP/ICMPv6, DHCP client replies and the -allow-lists. That covers host-networked SignalK `3000`, AIS, Boat Chat `8765` +allow-lists. That covers host-networked Signal K `3000`, AIS, Boat Chat `8765` (INPUT) and published container ports such as Heimdall `80/443` and Grafana (`DOCKER-USER`, matched on the published port). The allow-list therefore opens ports on both paths; the default `22` only lets SSH in. Keep Ethernet, a VPN or diff --git a/docs/README.md b/docs/README.md index 04978db..e1ba528 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,14 +21,14 @@ Boat Chat provider that sends data elsewhere. | Install on an existing Pi | [Getting started](GETTING_STARTED.md) → [Installation](INSTALLATION.md) | | Stage releases in a home lab | [Deployment](DEPLOYMENT.md) | | Carry an update to an offline boat | [Deployment: offline path](DEPLOYMENT.md#simple-path-one-command) | -| Understand every setting and command | [README reference](../README.md#quick-start-one-command-on-the-raspberry-pi) | +| Understand every setting and command | [README settings reference](../README.md#4-configure-the-vessel) | | Use or secure the Control Panel | [Control Panel](CONTROL_PANEL.md) | | Set up Boat Chat access, units and timezone | [Boat Chat setup](BOAT_CHAT.md) | | Manage MQTT accounts and access lists | [MQTT access control](MQTT.md) | | Reach dashboards from other devices | [Network access and security](NETWORK.md) | | Update, roll back, or install offline | [Updating and offline install](UPDATING.md) | | Set up ESP32 instrument displays (preview) | [Instrument displays](DISPLAYS.md) | -| Configure Boat Chat providers and units | [Boat Chat setup](../boat-chat/README.md) | +| Configure Boat Chat providers | [Boat Chat providers](../boat-chat/README.md) | | Review power sources and instrument-reading coverage | [Power monitoring](../boat-chat/POWER_MONITORING.md) | | Connect selected readings to VesselTwin | [Optional VesselTwin connection](VESSELTWIN.md) | | Develop, test, or cut a release | [Development](DEVELOPMENT.md) | @@ -46,13 +46,13 @@ should be reached through an SSH tunnel: ssh -L 8780:127.0.0.1:8780 YOUR_USER@PI ``` -To deliberately allow panel access from a trusted LAN, preview then run -`sudo vesselstackctl panel-access lan`; this changes only the panel bind and +The tunnel is the recommended default. Panel access from a trusted LAN is opt-in: preview +then run `sudo vesselstackctl panel-access lan`; this changes only the panel bind and uses unencrypted HTTP on all interfaces. A strict firewall may still block it. Restore the tunnel-only default with `sudo vesselstackctl panel-access local`. An optional VesselTwin connection uses the `signalk-vesseltwin` plugin, pairing, -and explicit reading opt-ins. Managed Docker/native SignalK installs preload the +and explicit reading opt-ins. Managed Docker/native Signal K installs preload the pinned 0.2.3 plugin by default; existing mode is untouched and uses the upstream install path. VesselStack never enables, pairs, or authorizes readings for you. See the [VesselTwin integration guide](VESSELTWIN.md) diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 2749280..4999f43 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -4,7 +4,38 @@ Purpose: update a running VesselStack with a reviewed release, roll back, and in [Back to README](../README.md) · [Documentation index](README.md) -Back up configuration and data, then use a reviewed release: +Back up first (see [Backups](../README.md#backups)); updating and rolling back are summarised +in the README under [Updating](../README.md#updating) and +[Upgrading to 0.6.0](../README.md#upgrading-to-060). + +## From the pinned installer + +Re-run the pinned release installer over `ssh -t`. It finds the installed system and offers +`vesselstackctl update `, which runs preflight, makes a verified backup and then +installs and starts the release: + +```bash +curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash +``` + +The command itself needs the unpacked release directory, so plain `vesselstackctl update` +with no argument fails. To run it by hand once a release is unpacked (the installer unpacks to +`/opt/vesselstack-release/`): + +```bash +sudo vesselstackctl update /opt/vesselstack-release/0.6.0 +``` + +## From the Control Panel + +An authenticated Control Panel session can check releases and run **Back up & install**. +Releases that GitHub publishes as prereleases appear only after you enable +**Include lab prereleases**. It needs a mounted external backup drive under `/media` or +`/mnt`; see [Control Panel](CONTROL_PANEL.md). + +## From a source checkout + +Use a reviewed tag from a git clone, with the dry run first: ```bash cd VesselStack @@ -21,85 +52,47 @@ sudo ./install.sh \ Review Compose and release notes before applying container changes. Prefer the installed configuration as input. If you do re-run with a file that still says -`GENERATE` (for example a fresh copy of the example), the installer now keeps +`GENERATE` (for example a fresh copy of the example), the installer keeps the already-installed InfluxDB, Grafana, MQTT, Boat Chat and control-panel secrets instead of generating new ones. A non-`GENERATE` value you set explicitly still wins. -Once a reviewed release directory is downloaded, the lifecycle command can -perform preflight, a verified pre-update backup, installation, and startup: +Once a reviewed release directory is downloaded, the lifecycle command performs +preflight, a verified pre-update backup, installation, and startup: ```bash -sudo vesselstackctl update /path/to/VesselStack-release -sudo VESSELSTACK_OFFLINE=true VESSELSTACK_WHEELHOUSE=/path/to/offline-bundle/wheelhouse \ - vesselstackctl update /path/to/VesselStack-release # no network +sudo vesselstackctl update /path/to/reviewed-release ``` -For Control Panel updates, stage the reviewed release beneath its restricted -root first. The panel rejects symlinks, missing release metadata, paths outside -this directory, and any entry anywhere in the release tree (`scripts/`, -`boat-chat/`, `templates/`, `systemd/`, ...) that is a symlink, owned by a user -other than root/the panel account, or group/world-writable: +Before it rewrites the installation the installer checks every value it will write to +`vesselstack.env`, `boat-chat.env` and `control-panel.env`, then builds the Python +environment in a staging directory and checks the upgrade path. A failed or +offline-without-bundle run stops with "Nothing was changed" and leaves the previous +installation, `vesselstack.env`, units and version marker as they were. A value the +installer refuses to write (an apostrophe combined with a backslash, double quote, dollar +sign or backtick, or a multi-line value) is caught by `--dry-run` itself, so `vesselstackctl +update` and the Control Panel updater stop before the backup, naming only the setting's key; +see [Upgrading to 0.6.0](../README.md#upgrading-to-060). The env files are then written +atomically (temporary file, then rename), so a failed run never leaves one truncated. When +the code, units and `compose.yml` have been replaced, the staged virtualenv is swapped +in and `installed-version` is written last. + +## Rollback + +Downgrades are refused. Rolling back a completed or half-finished upgrade is a restore of +the pre-update backup that `vesselstackctl update` takes automatically: ```bash -sudo install -d -m 0755 /var/lib/vesselstack/releases -sudo cp -a /path/to/vesselstack-x.y.z /var/lib/vesselstack/releases/ -sudo chown -R root:root /var/lib/vesselstack/releases/vesselstack-x.y.z -sudo chmod -R go-w /var/lib/vesselstack/releases/vesselstack-x.y.z +sudo vesselstackctl verify-backup /path/to/vesselstack-TIMESTAMP.tar.gz +sudo vesselstackctl restore /path/to/vesselstack-TIMESTAMP.tar.gz --yes ``` -Then enter `/var/lib/vesselstack/releases/vesselstack-x.y.z` in the panel. -Shell-initiated `vesselstackctl update` remains available for a trusted operator -and is not limited to the panel staging directory. - -The installer records the configuration schema in -`/opt/vesselstack/config/installed-version`. Any upgrade from 0.0.1 or newer to a -newer release is accepted: when a release needs no data change only the version -marker moves, and releases that do need one ship an executable -`scripts/migrations/.sh` that runs once, in version order. -The first release (0.0.1, formerly 1.0.0) migrates pre-release "0.1" layouts and -retains legacy InfluxDB names when the new bucket settings are absent. - -**Version renumbering (2026-10-25).** The project is pre-1.0, so the former -1.x releases were renumbered: 1.0.0 is now 0.0.1, 1.0.1 is 0.0.2, and 1.1.0, -1.2.0, 1.3.0, 1.4.0, 1.4.1 are 0.1.0, 0.2.0, 0.3.0, 0.4.0, 0.4.1 (see the -CHANGELOG for the table). Former `v1.x` tags and releases were removed. A machine -that installed a former 1.x release has `1.x.y` in `installed-version`, which -would look like a downgrade to `0.4.1`. `migrate.sh` and `get-vesselstack.sh` -therefore treat an installed `1.M.P` as `0.M.P` (1.0.0 and 1.0.1 as 0.0.1 and -0.0.2) when the target is a 0.x release, so `vesselstackctl update` and -`get-vesselstack.sh` upgrade it normally and rewrite the marker. If an older -`migrate.sh` refuses it anyway, run `sudo vesselstackctl backup`, then either -write the matching new number into `/opt/vesselstack/config/installed-version` -(for example `echo 0.4.1 | sudo tee /opt/vesselstack/config/installed-version` -for a 1.4.1 install; the configuration format is unchanged by the renumbering) or -reinstall 0.4.1 and `sudo vesselstackctl restore` the backup. To roll back the -renumbering itself, restore that backup on the previous release. - -**Downgrades are not supported.** Configuration and data may already use the -newer schema, so `install.sh` (including `--dry-run`) and `migrate.sh` refuse to -install an older release over a newer one and change nothing. To go back, restore -a backup taken before the upgrade (`sudo vesselstackctl restore ...`, see -Backups) or install the newer release again. Unknown or malformed version -markers are refused instead of guessed at. - -Install order is transactional. Preflight (`--dry-run` runs the same checks) -validates the upgrade path, the Compose template and, offline, the wheelhouse and -local images **before** any installed file is touched. The Python environment is -then built in a staging virtualenv next to the install; only if that succeeds are -the environment, units and code rewritten, the staged virtualenv swapped in, and -`installed-version` written last. A failed or offline-without-bundle run -therefore leaves the previous installation, `vesselstack.env`, units and version -marker exactly as they were (the installer says "Nothing was changed"). -Rollback of a completed upgrade is a restore of the pre-update backup that -`vesselstackctl update` takes automatically. - ## Offline install (no internet on the boat) Prepare a bundle on the home lab or any connected machine, carry it to the boat, then install without any network access. The guided installer does the checks, `--verify`, `--load` and install for you: -`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.5.7.tar.gz` +`sudo bash get-vesselstack.sh --offline --bundle /path/to/offline-bundle --tarball /path/to/vesselstack-0.6.0.tar.gz` (copy `get-vesselstack.sh`, the tarball with its `.sha256` and the bundle on the carry media; the bundle must be root-owned and not group/world-writable, and packages and Docker must already be installed). The manual steps follow: @@ -144,11 +137,11 @@ root-owned and not group/world-writable. `install.sh --offline` also requires `VESSELSTACK_WHEELHOUSE` to be the `wheelhouse/` directory of such a bundle, then runs `pip install --no-index --find-links $VESSELSTACK_WHEELHOUSE --only-binary=:all: --require-hashes -r boat-chat/requirements.lock`, so even a -bundle forged consistently cannot install a wheel the signed release did not +bundle forged consistently cannot install a wheel the pinned release did not pin. Containers start with `docker compose up -d --pull never`. If an image is missing the installer lists it with the `docker load` hint: `--start` fails before changing anything, while a plain render only warns. Offline mode never -contacts a registry or PyPI; SignalK/InfluxDB version bumps still need a new +contacts a registry or PyPI; Signal K/InfluxDB version bumps still need a new bundle. Image tarballs are covered by the manifest and the root-controlled path check but, unlike wheels, are not pinned by digest in the release (Compose images are tag-pinned). @@ -175,6 +168,5 @@ versions would read it as the directory, so the guided installer and the docs us variable) passes `--offline` to both the preflight and the install of that release; any value other than `true`/`false` in `VESSELSTACK_OFFLINE` is rejected. Without the flag or -variable the behaviour is unchanged apart from the hash-pinned lock. Rollback: -restore the pre-update backup; to return to the previous behaviour revert this -change (the lock file is inert for older releases). +variable the install runs online, with the hash-pinned lock. Rollback: restore the +pre-update backup (the lock file is inert for older releases). diff --git a/docs/VESSELTWIN.md b/docs/VESSELTWIN.md index 10c12da..85ba579 100644 --- a/docs/VESSELTWIN.md +++ b/docs/VESSELTWIN.md @@ -36,7 +36,7 @@ Check the account and rollout first: your account. - Sign up or check your account at the [VesselTwin home page](https://www.vesseltwin.io/). Use the home page rather than bookmarking a direct signup route. -- In VesselTwin, open **Settings > Integrations > SignalK**. If that entry is not +- In VesselTwin, open **Settings > Integrations > Signal K**. If that entry is not available there, stop and ask about access before installing the plugin. - The upstream plugin currently requires **Node.js 20.3 or newer** and a **Signal K 2.x** server. Those are upstream requirements, not a promise that @@ -176,7 +176,7 @@ VesselTwin. Remove or revoke the boat connection there as a separate step. | What you see | What to check | |---|---| | The Appstore has no VesselTwin entry | Confirm Signal K and Node requirements, then follow the [upstream install instructions](https://github.com/jclima/signalk-vesseltwin#install) for the actual managed configuration directory. | -| Pairing is unavailable or account access is denied | Recheck **Settings > Integrations > SignalK** and the account plan/rollout. A paid plan does not by itself prove access is enabled. | +| Pairing is unavailable or account access is denied | Recheck **Settings > Integrations > Signal K** and the account plan/rollout. A paid plan does not by itself prove access is enabled. | | It says paired, but no readings arrive | Confirm the master switch, the desired category switches, and the instance filters. Pairing alone sends nothing. | | A new engine, battery, or tank is missing | Check that Signal K is publishing a current value under a selected instance id, then save the plugin settings and wait for the next value. | | Values arrive but are not in the record | Open **Sensor channels** in VesselTwin, map each channel to equipment, and **Confirm** it. | diff --git a/install.sh b/install.sh index 5f26912..84336fd 100755 --- a/install.sh +++ b/install.sh @@ -381,11 +381,226 @@ if [ "$OFFLINE" = true ]; then echo "WARNING: load the images before running with --start or starting services." >&2 fi fi +# Environment-file values. One predicate decides what cannot be written safely so +# the preflight below and env_line can never disagree. +# env-line-begin +vesselstack_env_keys() { + cat <<'EOF' +BOAT_NAME +BOAT_TYPE +BOAT_MMSI +BOAT_CALLSIGN +BOAT_LOA_M +BOAT_BEAM_M +BOAT_TIMEZONE +BOAT_UNITS +VESSELSTACK_USER +VESSELSTACK_UID +VESSELSTACK_GID +VESSELSTACK_ROOT +VESSELSTACK_DATA +VESSELSTACK_BACKUP +SIGNALK_MODE +SIGNALK_VERSION +SIGNALK_URL +SIGNALK_VESSELTWIN_PRELOAD +SOCKETCAN_ENABLE +SOCKETCAN_INTERFACE +SOCKETCAN_BITRATE +VESSELSTACK_CAN_CREATE_VCAN +AIS_ENABLE +AIS_IMAGE +AIS_DEVICE +AIS_CATCHER_ARGS +AIS_WEB_PORT +AIS_TCP_PORT +VESSELSTACK_DISABLED_COMPONENTS +VESSELSTACK_UNTRUSTED_INTERFACE +VESSELSTACK_FIREWALL_ENABLE +VESSELSTACK_FIREWALL_STRICT +VESSELSTACK_FIREWALL_ALLOW_TCP +VESSELSTACK_FIREWALL_ALLOW_UDP +CONTROL_PANEL_HOST +CONTROL_PANEL_PORT +TELEGRAM_ENABLE +TELEMETRY_INDEXER_ENABLE +NODE_EXPORTER_ENABLE +DISPLAYS_ENABLE +DISPLAYS_BRIDGE_MQTT_USERNAME +DISPLAYS_SIGNALK_TOKEN +DISPLAYS_HA_TOKEN +HOME_ASSISTANT_URL +HOME_ASSISTANT_TOKEN +INFLUXDB_URL +INFLUXDB_PORT +INFLUXDB_CONTAINER_NAME +INFLUXDB_ORG +INFLUXDB_USERNAME +INFLUXDB_RAW_BUCKET +INFLUXDB_HISTORY_BUCKET +INFLUXDB_HOME_ASSISTANT_BUCKET +INFLUXDB_AIS_BUCKET +INFLUXDB_RAW_RETENTION +INFLUXDB_PASSWORD +INFLUXDB_TOKEN +GRAFANA_ADMIN_PASSWORD +GRAFANA_PORT +HEIMDALL_PORT +HEIMDALL_HTTPS_PORT +PROMETHEUS_PORT +PROMETHEUS_RETENTION +MQTT_USERNAME +MQTT_PASSWORD +MQTT_PORT +VESSELSTACK_DATA_BIND_ADDRESS +VESSELSTACK_WEB_BIND_ADDRESS +HOMEASSISTANT_BIND_ADDRESS +EOF +} +# Keys written to boat-chat.env and control-panel.env beyond the list above. +extra_env_keys() { + printf '%s\n' BOAT_CHAT_PROVIDER BOAT_CHAT_HOST BOAT_CHAT_PORT \ + BOAT_CHAT_SETTINGS_TOKEN BOAT_CHAT_ACCESS_TOKEN CONTROL_PANEL_TOKEN +} +# env_value_problem VALUE: prints the reason and returns 0 when VALUE cannot be +# written. Compose cannot parse the '\'' idiom, so a value with a single quote is +# double-quoted, which is only portable when it has no \ " $ or backtick. +env_value_problem() { + local value=$1 + case $value in + *$'\n'*|*$'\r'*) echo "a multi-line value"; return 0 ;; + esac + if [[ $value == *\'* && $value == *[\\\"\$\`]* ]]; then + echo "a single quote combined with a backslash, double quote, dollar sign or backtick" + return 0 + fi + return 1 +} +# env_line KEY VALUE: one env-file line that bash (`source`), systemd +# EnvironmentFile, Python shlex and Docker Compose all read back identically. +# Plain values stay bare; others are POSIX single-quoted; a value with a single +# quote is double-quoted (see env_value_problem for what is refused). +env_line() { + local key=$1 value=$2 reason + if reason=$(env_value_problem "$value"); then + echo "Refusing value for $key: $reason" >&2 + exit 1 + fi + if [ -n "$value" ] && [[ $value =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then + printf '%s=%s\n' "$key" "$value" + elif [[ $value != *\'* ]]; then + printf "%s='%s'\n" "$key" "$value" + else + printf '%s="%s"\n' "$key" "$value" + fi +} +# env-line-end + +# Env files are written to a 0600 temp file beside the target and renamed into +# place only when every line was written, so a failure never leaves a truncated file. +ENV_TEMPS=() +new_env_temp() { + NEW_ENV_TEMP=$(mktemp "$VESSELSTACK_ROOT/config/.$1.XXXXXX") + ENV_TEMPS+=("$NEW_ENV_TEMP") +} +cleanup_env_temps() { + [ "${#ENV_TEMPS[@]}" -eq 0 ] || rm -f -- "${ENV_TEMPS[@]}" + return 0 +} + +# Read-only check of every value that will reach vesselstack.env, boat-chat.env +# and control-panel.env, run before anything is changed and before the dry run +# exits. Reports KEY names and reasons only, never values. +env_values_preflight() { + local key bad=0 reason + while IFS= read -r key; do + if reason=$(env_value_problem "${!key:-}"); then + echo " $key: $reason" >&2 + bad=1 + fi + done < <(vesselstack_env_keys; extra_env_keys) + if reason=$(env_value_problem "$(<"$SCRIPT_DIR/VERSION")"); then + echo " VERSION: $reason" >&2 + bad=1 + fi + if [ "$bad" -ne 0 ]; then + echo "Refusing to continue: the settings above cannot be written safely to the environment files." >&2 + echo "Edit them in $CONFIG_FILE (values cannot combine a single quote with a backslash, double quote, dollar sign or backtick, and cannot span lines), then run again. Nothing was changed. See README.md, \"Upgrading to 0.6.0\"." >&2 + exit 1 + fi +} +generate_secret() { + local current=$1 + if [ -z "$current" ] || [ "$current" = GENERATE ]; then + openssl rand -hex 32 + else + printf '%s' "$current" + fi +} + +# Preserve EVERY generated secret across re-renders. When the input config says +# GENERATE (or leaves a secret empty, e.g. a fresh copy of the example file used +# to upgrade an installed system) the value already installed wins. InfluxDB and +# Grafana only read their initial credentials on first start and Mosquitto's +# password file is keyed to MQTT_PASSWORD, so regenerating any of them would +# silently desynchronise the config from the running services. +installed_env="$VESSELSTACK_ROOT/config/vesselstack.env" +installed_value() { + # Prints the installed value of $1 from the root-owned, mode-600 config. + [ -r "$installed_env" ] || return 0 + ( + set +u + unset "$1" + # shellcheck disable=SC1090 + source "$installed_env" >/dev/null 2>&1 || exit 0 + value=${!1:-} + [ "$value" = GENERATE ] || printf '%s' "$value" + ) +} +preserve_secret() { + # $2 optionally names a fallback app env file. Those files are writable by + # the service user, so only plain hex-like values are accepted from them and + # they are never evaluated. + local key=$1 file=${2:-} current=${!1:-GENERATE} kept= + if [ -n "$current" ] && [ "$current" != GENERATE ]; then + return 0 + fi + kept=$(installed_value "$key") + if [ -z "$kept" ] && [ -n "$file" ] && [ -r "$file" ]; then + kept=$(sed -n "s/^${key}=//p" "$file" | tail -n 1) + [[ "$kept" =~ ^[A-Za-z0-9._-]+$ ]] || kept= + [ "$kept" != GENERATE ] || kept= + fi + if [ -n "$kept" ]; then + printf -v "$key" '%s' "$kept" + fi +} +preserve_secret INFLUXDB_PASSWORD +preserve_secret INFLUXDB_TOKEN +preserve_secret GRAFANA_ADMIN_PASSWORD +preserve_secret MQTT_PASSWORD +preserve_secret BOAT_CHAT_SETTINGS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" +preserve_secret BOAT_CHAT_ACCESS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" +preserve_secret CONTROL_PANEL_TOKEN "$VESSELSTACK_ROOT/config/control-panel.env" + +INFLUXDB_PASSWORD=$(generate_secret "${INFLUXDB_PASSWORD:-GENERATE}") +INFLUXDB_TOKEN=$(generate_secret "${INFLUXDB_TOKEN:-GENERATE}") +GRAFANA_ADMIN_PASSWORD=$(generate_secret "${GRAFANA_ADMIN_PASSWORD:-GENERATE}") +BOAT_CHAT_SETTINGS_TOKEN=$(generate_secret "${BOAT_CHAT_SETTINGS_TOKEN:-GENERATE}") +BOAT_CHAT_ACCESS_TOKEN=$(generate_secret "${BOAT_CHAT_ACCESS_TOKEN:-GENERATE}") +CONTROL_PANEL_TOKEN=$(generate_secret "${CONTROL_PANEL_TOKEN:-GENERATE}") +MQTT_PASSWORD=$(generate_secret "${MQTT_PASSWORD:-GENERATE}") + +# Secrets are resolved (preserved or generated) before the preflight so the +# check covers the values that will actually be written; read-only, so it is +# also safe ahead of the dry run. +env_values_preflight + PLUGIN_STAGE= if [ "$SIGNALK_VESSELTWIN_PRELOAD" = true ] && [ "$SIGNALK_MODE" != existing ] && [ "$OFFLINE" = false ] && [ "$DRY_RUN" -eq 0 ]; then PLUGIN_STAGE=$(mktemp -d "${TMPDIR:-/tmp}/vesselstack-signalk-plugin-stage.XXXXXX") chmod 0700 "$PLUGIN_STAGE" - trap 'rm -rf -- "$PLUGIN_STAGE"' EXIT + trap 'rm -rf -- "$PLUGIN_STAGE"; cleanup_env_temps' EXIT python3 "$SCRIPT_DIR/scripts/preload-signalk-vesseltwin.py" --mode "$SIGNALK_MODE" --data "$VESSELSTACK_DATA" \ --stage "$PLUGIN_STAGE" --prepare fi @@ -416,6 +631,23 @@ if [ "$DRY_RUN" -eq 1 ]; then fi [ "$(id -u)" -eq 0 ] || { echo "Installation requires root" >&2; exit 1; } + +# Remove temp files left in config/ by an earlier run that was killed between +# creating and renaming an env file. Only the exact names new_env_temp creates +# (.vesselstack.env.XXXXXX, .boat-chat.env.XXXXXX, .control-panel.env.XXXXXX with +# six alphanumeric characters); nothing else is touched. +remove_stale_env_temps() { + local dir="$VESSELSTACK_ROOT/config" name file + [ -d "$dir" ] || return 0 + for name in vesselstack.env boat-chat.env control-panel.env; do + for file in "$dir/.$name."??????; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + [[ ${file##*/."$name".} =~ ^[A-Za-z0-9]{6}$ ]] || continue + rm -f -- "$file" + done + done +} +remove_stale_env_temps # Same checks as the dry run; a missing Mosquitto image is pulled now, before # any file is changed, so an offline boat fails here with nothing touched. displays_preflight --pull-image @@ -427,7 +659,7 @@ install -d -m 0755 "$SYSTEMD_DIR" "$SBIN_DIR" # nothing: no env, units, code or installed-version are touched. install -d -m 0755 "$VESSELSTACK_ROOT" STAGING_DIR=$(mktemp -d "$VESSELSTACK_ROOT/.venv-staging.XXXXXX") -trap 'rm -rf -- "$STAGING_DIR" ${PLUGIN_STAGE:+"$PLUGIN_STAGE"}' EXIT +trap 'rm -rf -- "$STAGING_DIR" ${PLUGIN_STAGE:+"$PLUGIN_STAGE"}; cleanup_env_temps' EXIT STAGING_VENV="$STAGING_DIR/venv" pip_args=(install --disable-pip-version-check --only-binary=:all: --require-hashes) if [ "$OFFLINE" = true ]; then @@ -450,68 +682,6 @@ if [ "$DISPLAYS_ENABLE" = true ]; then "$VESSELSTACK_ROOT/displays/.venv" "$SCRIPT_DIR/displays/requirements.txt" fi -generate_secret() { - local current=$1 - if [ -z "$current" ] || [ "$current" = GENERATE ]; then - openssl rand -hex 32 - else - printf '%s' "$current" - fi -} - -# Preserve EVERY generated secret across re-renders. When the input config says -# GENERATE (or leaves a secret empty, e.g. a fresh copy of the example file used -# to upgrade an installed system) the value already installed wins. InfluxDB and -# Grafana only read their initial credentials on first start and Mosquitto's -# password file is keyed to MQTT_PASSWORD, so regenerating any of them would -# silently desynchronise the config from the running services. -installed_env="$VESSELSTACK_ROOT/config/vesselstack.env" -installed_value() { - # Prints the installed value of $1 from the root-owned, mode-600 config. - [ -r "$installed_env" ] || return 0 - ( - set +u - unset "$1" - # shellcheck disable=SC1090 - source "$installed_env" >/dev/null 2>&1 || exit 0 - value=${!1:-} - [ "$value" = GENERATE ] || printf '%s' "$value" - ) -} -preserve_secret() { - # $2 optionally names a fallback app env file. Those files are writable by - # the service user, so only plain hex-like values are accepted from them and - # they are never evaluated. - local key=$1 file=${2:-} current=${!1:-GENERATE} kept= - if [ -n "$current" ] && [ "$current" != GENERATE ]; then - return 0 - fi - kept=$(installed_value "$key") - if [ -z "$kept" ] && [ -n "$file" ] && [ -r "$file" ]; then - kept=$(sed -n "s/^${key}=//p" "$file" | tail -n 1) - [[ "$kept" =~ ^[A-Za-z0-9._-]+$ ]] || kept= - [ "$kept" != GENERATE ] || kept= - fi - if [ -n "$kept" ]; then - printf -v "$key" '%s' "$kept" - fi -} -preserve_secret INFLUXDB_PASSWORD -preserve_secret INFLUXDB_TOKEN -preserve_secret GRAFANA_ADMIN_PASSWORD -preserve_secret MQTT_PASSWORD -preserve_secret BOAT_CHAT_SETTINGS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" -preserve_secret BOAT_CHAT_ACCESS_TOKEN "$VESSELSTACK_ROOT/config/boat-chat.env" -preserve_secret CONTROL_PANEL_TOKEN "$VESSELSTACK_ROOT/config/control-panel.env" - -INFLUXDB_PASSWORD=$(generate_secret "${INFLUXDB_PASSWORD:-GENERATE}") -INFLUXDB_TOKEN=$(generate_secret "${INFLUXDB_TOKEN:-GENERATE}") -GRAFANA_ADMIN_PASSWORD=$(generate_secret "${GRAFANA_ADMIN_PASSWORD:-GENERATE}") -BOAT_CHAT_SETTINGS_TOKEN=$(generate_secret "${BOAT_CHAT_SETTINGS_TOKEN:-GENERATE}") -BOAT_CHAT_ACCESS_TOKEN=$(generate_secret "${BOAT_CHAT_ACCESS_TOKEN:-GENERATE}") -CONTROL_PANEL_TOKEN=$(generate_secret "${CONTROL_PANEL_TOKEN:-GENERATE}") -MQTT_PASSWORD=$(generate_secret "${MQTT_PASSWORD:-GENERATE}") - # Ownership model for VESSELSTACK_DATA: the directory itself and every managed # intermediate component is ROOT-owned and not writable by the service user, so the # service user can never rename, replace or symlink an entry that root later @@ -714,6 +884,7 @@ restore_telemetry_on_exit() { echo "Installer staging cleanup failed" >&2 if [ "$rc" -eq 0 ]; then rc=1; fi fi + cleanup_env_temps if [ "$rc" -ne 0 ] || [ "$START" -eq 0 ]; then if [ "$telemetry_sample_was_active" -eq 1 ]; then systemctl start vesselstack-telemetry-sample.timer 2>/dev/null || true; fi if [ "$telemetry_indexer_was_active" -eq 1 ]; then systemctl start vesselstack-telemetry-indexer.timer 2>/dev/null || true; fi @@ -901,108 +1072,15 @@ install -m 0644 "$SCRIPT_DIR/systemd/vesselstack-signalk.service.in" \ install -m 0644 "$SCRIPT_DIR/systemd/vesselstack-socketcan.service.in" \ "$VESSELSTACK_ROOT/systemd/vesselstack-socketcan.service.in" -# env_line KEY VALUE: one env-file line that bash (`source`), systemd -# EnvironmentFile, Python shlex and Docker Compose all read back identically. -# Plain values stay bare; others are POSIX single-quoted. Compose cannot parse the -# '\'' idiom, so a value with a single quote is double-quoted instead, which is -# only portable when it has no \ " $ or backtick (such values are rejected). -# env-line-begin -env_line() { - local key=$1 value=$2 - case $value in - *$'\n'*|*$'\r'*) echo "Refusing multi-line value for $key" >&2; exit 1 ;; - esac - if [ -n "$value" ] && [[ $value =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then - printf '%s=%s\n' "$key" "$value" - elif [[ $value != *\'* ]]; then - printf "%s='%s'\n" "$key" "$value" - elif [[ $value == *[\\\"\$\`]* ]]; then - echo "Refusing value for $key: a single quote cannot be combined with backslash, double quote, dollar or backtick" >&2 - exit 1 - else - printf '%s="%s"\n' "$key" "$value" - fi -} -# env-line-end - -install -m 0600 /dev/null "$VESSELSTACK_ROOT/config/vesselstack.env" +new_env_temp vesselstack.env; vs_env_tmp=$NEW_ENV_TEMP while IFS= read -r key; do - env_line "$key" "${!key:-}" >> "$VESSELSTACK_ROOT/config/vesselstack.env" -done <<'EOF' -BOAT_NAME -BOAT_TYPE -BOAT_MMSI -BOAT_CALLSIGN -BOAT_LOA_M -BOAT_BEAM_M -BOAT_TIMEZONE -BOAT_UNITS -VESSELSTACK_USER -VESSELSTACK_UID -VESSELSTACK_GID -VESSELSTACK_ROOT -VESSELSTACK_DATA -VESSELSTACK_BACKUP -SIGNALK_MODE -SIGNALK_VERSION -SIGNALK_URL -SIGNALK_VESSELTWIN_PRELOAD -SOCKETCAN_ENABLE -SOCKETCAN_INTERFACE -SOCKETCAN_BITRATE -VESSELSTACK_CAN_CREATE_VCAN -AIS_ENABLE -AIS_IMAGE -AIS_DEVICE -AIS_CATCHER_ARGS -AIS_WEB_PORT -AIS_TCP_PORT -VESSELSTACK_DISABLED_COMPONENTS -VESSELSTACK_UNTRUSTED_INTERFACE -VESSELSTACK_FIREWALL_ENABLE -VESSELSTACK_FIREWALL_STRICT -VESSELSTACK_FIREWALL_ALLOW_TCP -VESSELSTACK_FIREWALL_ALLOW_UDP -CONTROL_PANEL_HOST -CONTROL_PANEL_PORT -TELEGRAM_ENABLE -TELEMETRY_INDEXER_ENABLE -NODE_EXPORTER_ENABLE -DISPLAYS_ENABLE -DISPLAYS_BRIDGE_MQTT_USERNAME -DISPLAYS_SIGNALK_TOKEN -DISPLAYS_HA_TOKEN -HOME_ASSISTANT_URL -HOME_ASSISTANT_TOKEN -INFLUXDB_URL -INFLUXDB_PORT -INFLUXDB_CONTAINER_NAME -INFLUXDB_ORG -INFLUXDB_USERNAME -INFLUXDB_RAW_BUCKET -INFLUXDB_HISTORY_BUCKET -INFLUXDB_HOME_ASSISTANT_BUCKET -INFLUXDB_AIS_BUCKET -INFLUXDB_RAW_RETENTION -INFLUXDB_PASSWORD -INFLUXDB_TOKEN -GRAFANA_ADMIN_PASSWORD -GRAFANA_PORT -HEIMDALL_PORT -HEIMDALL_HTTPS_PORT -PROMETHEUS_PORT -PROMETHEUS_RETENTION -MQTT_USERNAME -MQTT_PASSWORD -MQTT_PORT -VESSELSTACK_DATA_BIND_ADDRESS -VESSELSTACK_WEB_BIND_ADDRESS -HOMEASSISTANT_BIND_ADDRESS -EOF + env_line "$key" "${!key:-}" >> "$vs_env_tmp" +done < <(vesselstack_env_keys) +mv -f -- "$vs_env_tmp" "$VESSELSTACK_ROOT/config/vesselstack.env" # Retain provider credentials and optional Boat Chat settings across installer # re-renders while replacing the integration values owned by VesselStack. -boat_chat_temp=$(mktemp) +new_env_temp boat-chat.env; boat_chat_temp=$NEW_ENV_TEMP if [ -r "$VESSELSTACK_ROOT/config/boat-chat.env" ]; then cp "$VESSELSTACK_ROOT/config/boat-chat.env" "$boat_chat_temp" fi @@ -1049,16 +1127,16 @@ done env_line INFLUXDB_HOME_ASSISTANT_BUCKET "$INFLUXDB_HOME_ASSISTANT_BUCKET" env_line INFLUXDB_AIS_BUCKET "$INFLUXDB_AIS_BUCKET" } >> "$boat_chat_temp" -install -m 0600 "$boat_chat_temp" "$VESSELSTACK_ROOT/config/boat-chat.env" -rm -f -- "$boat_chat_temp" -chown "$VESSELSTACK_USER:$VESSELSTACK_USER" "$VESSELSTACK_ROOT/config/boat-chat.env" +chown "$VESSELSTACK_USER:$VESSELSTACK_USER" "$boat_chat_temp" +mv -f -- "$boat_chat_temp" "$VESSELSTACK_ROOT/config/boat-chat.env" -install -m 0600 /dev/null "$VESSELSTACK_ROOT/config/control-panel.env" +new_env_temp control-panel.env; control_panel_temp=$NEW_ENV_TEMP { env_line CONTROL_PANEL_HOST "$CONTROL_PANEL_HOST" env_line CONTROL_PANEL_PORT "$CONTROL_PANEL_PORT" env_line CONTROL_PANEL_TOKEN "$CONTROL_PANEL_TOKEN" -} >> "$VESSELSTACK_ROOT/config/control-panel.env" +} >> "$control_panel_temp" +mv -f -- "$control_panel_temp" "$VESSELSTACK_ROOT/config/control-panel.env" # Vessel identity is generated into a root-owned temporary file in the (root-owned) # application directory and renamed into place; the service user can only read it. diff --git a/scripts/get-vesselstack.sh b/scripts/get-vesselstack.sh index 01be9fd..b9aca42 100755 --- a/scripts/get-vesselstack.sh +++ b/scripts/get-vesselstack.sh @@ -2,7 +2,7 @@ # VesselStack guided installer (bootstrap). # # curl -fsSL https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh | sudo bash -# curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.5.7/get-vesselstack.sh | sudo bash +# curl -fsSL https://github.com/jclima/VesselStack/releases/download/v0.6.0/get-vesselstack.sh | sudo bash # # Cautious alternative: download it, read it, then run it. # curl -fsSLO https://github.com/jclima/VesselStack/releases/latest/download/get-vesselstack.sh @@ -24,7 +24,7 @@ set -euo pipefail umask 022 # Baked from VERSION by build-package.sh (and checked by tests/test-distribution.sh). -DEFAULT_VERSION="0.5.7" +DEFAULT_VERSION="0.6.0" GITHUB_RELEASES="https://github.com/jclima/VesselStack/releases/download" APT_PACKAGES=(ca-certificates curl jq openssl python3 python3-venv rsync tar gzip) diff --git a/tests/test-clean-install.sh b/tests/test-clean-install.sh index 6fcc6e5..ac3d897 100755 --- a/tests/test-clean-install.sh +++ b/tests/test-clean-install.sh @@ -699,6 +699,47 @@ expect_unchanged_failure "real run with ':' password" xinstall "$X_APP/installer printf '\nMQTT_PASSWORD=" padded"\n' >> "$BADPW" expect_unchanged_failure "dry-run with padded password" xinstall "$X_APP/installer/install.sh" --config "$BADPW" --dry-run +# 1b. A value the env files cannot hold safely (a single quote with a $) is refused +# by a dry run and by a real run BEFORE anything changes: the installed env files +# stay byte-identical, no temp file is left behind, and the message names only the +# key, never the value. +BADVAL="$TEST_ROOT/badval.env" +cp "$XENV" "$BADVAL" +cat >> "$BADVAL" <<'CFG' +HOME_ASSISTANT_TOKEN="it's \$SECRET" +CFG +env_before=$(sha256sum "$XENV" "$X_APP/config/boat-chat.env" "$X_APP/config/control-panel.env") +expect_unchanged_failure "dry-run with unsafe env value" xinstall "$X_APP/installer/install.sh" --config "$BADVAL" --dry-run +grep -q "HOME_ASSISTANT_TOKEN" "$TEST_ROOT/failure.out" +grep -q 'Upgrading to 0.6.0' "$TEST_ROOT/failure.out" +if grep -q 'SECRET' "$TEST_ROOT/failure.out"; then echo "the refusal printed the value" >&2; exit 1; fi +expect_unchanged_failure "real run with unsafe env value" xinstall "$X_APP/installer/install.sh" --config "$BADVAL" +grep -q "HOME_ASSISTANT_TOKEN" "$TEST_ROOT/failure.out" +test "$env_before" = "$(sha256sum "$XENV" "$X_APP/config/boat-chat.env" "$X_APP/config/control-panel.env")" +test -z "$(find "$X_APP/config" -maxdepth 1 -name '.*.env.*' -print -quit)" +# A safe value with an apostrophe still passes the dry run. +GOODVAL="$TEST_ROOT/goodval.env" +cp "$XENV" "$GOODVAL" +cat >> "$GOODVAL" <<'CFG' +HOME_ASSISTANT_TOKEN="it's fine" +CFG +xinstall "$X_APP/installer/install.sh" --config "$GOODVAL" --dry-run | grep -q 'Preflight passed' + +# 1c. Stale env temp files (exact mktemp names) are removed by a real run; a dry run +# leaves them, and similarly named files are never touched. +for stale in .vesselstack.env.AbC123 .boat-chat.env.xYz789 .control-panel.env.000000; do : > "$X_APP/config/$stale"; done +for keep in .vesselstack.env.short .vesselstack.env.AbC1234 .other.env.AbC123 vesselstack.env.AbC123; do : > "$X_APP/config/$keep"; done +xinstall "$X_APP/installer/install.sh" --config "$XENV" --dry-run >/dev/null +test -e "$X_APP/config/.vesselstack.env.AbC123" +xinstall "$X_APP/installer/install.sh" --config "$XENV" >/dev/null +for stale in .vesselstack.env.AbC123 .boat-chat.env.xYz789 .control-panel.env.000000; do + test ! -e "$X_APP/config/$stale" || { echo "stale temp $stale was not removed" >&2; exit 1; } +done +for keep in .vesselstack.env.short .vesselstack.env.AbC1234 .other.env.AbC123 vesselstack.env.AbC123; do + test -e "$X_APP/config/$keep" || { echo "$keep should not have been removed" >&2; exit 1; } + rm -f "$X_APP/config/$keep" +done + # 2. Bridge username colliding with the administrator account. COLLIDE="$TEST_ROOT/collide.env" cp "$XENV" "$COLLIDE" diff --git a/tests/test_access_security.py b/tests/test_access_security.py index 5cabf27..28a2382 100644 --- a/tests/test_access_security.py +++ b/tests/test_access_security.py @@ -204,6 +204,94 @@ def test_newline_cr_nul_values_are_rejected_without_writing(self): self.assertFalse(self.env_file.exists() and "8765" in self.env_file.read_text()) + def test_settings_post_requires_the_access_code_like_get(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + body = {"settings": {"BOAT_CHAT_PROVIDER": "ollama"}} + # Loopback with no settings token passes the admin check, but not the access check. + self.assertEqual(401, self.call("POST", "/api/settings", body)[0]) + self.assertEqual(401, self.call("POST", "/api/settings", body, token="wrong")[0]) + self.assertFalse(self.env_file.exists() and "ollama" in self.env_file.read_text()) + self.assertEqual(200, self.call("POST", "/api/settings", body, token="right-token")[0]) + self.assertIn("ollama", self.env_file.read_text()) + + def test_settings_post_needs_both_codes_when_they_differ(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + os.environ["BOAT_CHAT_SETTINGS_TOKEN"] = "settings-secret" + data = json.dumps({"settings": {"BOAT_CHAT_PROVIDER": "ollama"}}).encode() + + def post(**headers): + request = urllib.request.Request(f"http://127.0.0.1:{self.server.server_port}/api/settings", data=data, method="POST", + headers={"Content-Type": "application/json", **headers}) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + self.assertEqual(401, post(**{"X-Boat-Chat-Token": "settings-secret"})) + self.assertEqual(403, post(**{"X-Boat-Chat-Token": "right-token"})) + self.assertEqual(401, post(**{"X-Boat-Chat-Token": "settings-secret", "X-Boat-Chat-Access-Token": "wrong"})) + self.assertEqual(200, post(**{"X-Boat-Chat-Token": "settings-secret", "X-Boat-Chat-Access-Token": "right-token"})) + + def test_access_code_cannot_be_cleared_unless_open_mode(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + app.update_settings({"BOAT_CHAT_ACCESS_TOKEN": "right-token", "BOAT_CHAT_PROVIDER": "ollama"}) + before = self.env_file.read_text() + for bad in (None, "", " "): + status, body = self.call("POST", "/api/settings", {"settings": {"BOAT_CHAT_ACCESS_TOKEN": bad}}, token="right-token") + self.assertEqual(400, status, repr(bad)) + self.assertEqual("The access code cannot be cleared; set a new one instead.", body["error"]) + self.assertEqual(before, self.env_file.read_text()) + self.assertEqual(200, self.call("POST", "/api/settings", {"settings": {"BOAT_CHAT_ACCESS_TOKEN": "new-token"}}, token="right-token")[0]) + os.environ["BOAT_CHAT_ALLOW_OPEN"] = "true" + app.update_settings({"BOAT_CHAT_ACCESS_TOKEN": None}) + + def test_settings_get_and_head_accept_the_access_code_header(self): + os.environ["BOAT_CHAT_ACCESS_TOKEN"] = "right-token" + os.environ["BOAT_CHAT_SETTINGS_TOKEN"] = "settings-secret" + + def send(method, **headers): + request = urllib.request.Request(f"http://127.0.0.1:{self.server.server_port}/api/settings", method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + for method in ("GET", "HEAD"): + self.assertEqual(401, send(method), method) + self.assertEqual(401, send(method, **{"X-Boat-Chat-Access-Token": "wrong"}), method) + self.assertEqual(200, send(method, **{"X-Boat-Chat-Access-Token": "right-token"}), method) + + def test_blank_required_settings_are_rejected_without_writing(self): + os.environ["BOAT_CHAT_ALLOW_OPEN"] = "true" + app.update_settings({"BOAT_CHAT_PORT": "9000", "BOAT_CHAT_MAX_TOKENS": "800", "BOAT_CHAT_MODEL": "m1"}) + before = self.env_file.read_text() + for key in ("BOAT_CHAT_HOST", "BOAT_CHAT_PORT", "BOAT_CHAT_MAX_TOKENS", "BOAT_CHAT_CONTEXT_CHARS", "BOAT_CHAT_PROVIDER", "BOAT_CHAT_MODEL"): + status, body = self.call("POST", "/api/settings", {"settings": {"BOAT_CHAT_FALLBACK_PROVIDER": "ollama", key: ""}}) + self.assertEqual(400, status, key) + self.assertIn(key, body["error"]) + self.assertEqual(before, self.env_file.read_text(), key) + # The exact wipe the stale Settings dialog used to send. + wipe = {k: "" for k in ("BOAT_CHAT_HOST", "BOAT_CHAT_PORT", "BOAT_CHAT_MAX_TOKENS", "BOAT_CHAT_MODEL", "BOAT_CHAT_FALLBACK_MODEL")} + self.assertEqual(400, self.call("POST", "/api/settings", {"settings": wipe})[0]) + self.assertEqual(before, self.env_file.read_text()) + for bad in ("abc", "0", "70000"): + with self.assertRaises(ValueError): + app.update_settings({"BOAT_CHAT_PORT": bad}) + self.assertEqual(before, self.env_file.read_text()) + + def test_explicit_clear_and_blank_secret_semantics_are_kept(self): + os.environ["BOAT_CHAT_ALLOW_OPEN"] = "true" + app.update_settings({"BOAT_CHAT_MODEL": "m1", "BOAT_CHAT_FALLBACK_MODEL": "f1", "OPENAI_API_KEY": "sk-x"}) + app.update_settings({"BOAT_CHAT_MODEL": None, "BOAT_CHAT_FALLBACK_MODEL": None, "OPENAI_API_KEY": ""}) + saved = env_config.parse_env_file(self.env_file, app.SETTING_KEYS) + self.assertEqual("", saved["BOAT_CHAT_MODEL"]) + self.assertEqual("", saved["BOAT_CHAT_FALLBACK_MODEL"]) + self.assertEqual("sk-x", saved["OPENAI_API_KEY"]) + # An explicit None still means "clear", also for required keys (reset to default). + app.update_settings({"BOAT_CHAT_PORT": None}) + self.assertEqual("", env_config.parse_env_file(self.env_file, app.SETTING_KEYS)["BOAT_CHAT_PORT"]) + + class PrecedenceTests(unittest.TestCase): def test_single_env_variable_selects_the_file_for_both_services(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_control_panel.py b/tests/test_control_panel.py index 0a63caf..816d60a 100644 --- a/tests/test_control_panel.py +++ b/tests/test_control_panel.py @@ -232,6 +232,19 @@ def test_displays_bridge_is_opt_in_and_token_is_secret(self) -> None: self.assertEqual(panel.FIELD_BY_KEY["DISPLAYS_HA_TOKEN"]["kind"], "secret") self.assertEqual(panel.FIELD_BY_KEY["DISPLAYS_HA_TOKEN"]["section"], "Displays") + def test_signal_k_naming_units_choices_and_help_text(self) -> None: + sections = {item["section"] for item in panel.FIELDS} + self.assertIn("Signal K", sections) + self.assertNotIn("SignalK", sections) + for item in panel.FIELDS: + self.assertNotIn("SignalK", item["label"], item["key"]) + self.assertNotIn("SignalK", item["description"], item["key"]) + self.assertEqual(panel.FIELD_BY_KEY["BOAT_UNITS"]["choices"], ["us_customary", "metric"]) + self.assertEqual(panel.FIELD_BY_KEY["SIGNALK_MODE"]["choices"], ["existing", "docker", "native"]) + for key in ("SIGNALK_MODE", "SOCKETCAN_BITRATE", "AIS_DEVICE", "BOAT_TYPE", "BOAT_TIMEZONE"): + self.assertTrue(panel.FIELD_BY_KEY[key]["description"], key) + self.assertEqual(panel.FIELD_BY_KEY["BOAT_TIMEZONE"]["label"], "Timezone (for example Europe/Lisbon)") + def test_displays_values_mirror_the_installer_checks(self) -> None: user = panel.FIELD_BY_KEY["DISPLAYS_BRIDGE_MQTT_USERNAME"] for good in ("vesselstack-bridge", "a", "A_b.c-9", "x" * 64, ""): diff --git a/tests/test_control_panel_ui.cjs b/tests/test_control_panel_ui.cjs index c2482aa..d44ebd4 100644 --- a/tests/test_control_panel_ui.cjs +++ b/tests/test_control_panel_ui.cjs @@ -2,17 +2,18 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); -const source = fs.readFileSync('control-panel/static/app.js', 'utf8') + '\nthis.__ui = { state, statusClass, isConfigDirty, currentSettings, lock, setBusy, renderComponents, checkReleases, startOperation, renderOperation, operationSucceeded, refreshStatus, renderReleases, updateReleaseControls };'; +const source = fs.readFileSync('control-panel/static/app.js', 'utf8') + '\nthis.__ui = { state, statusClass, isConfigDirty, currentSettings, lock, setBusy, renderComponents, checkReleases, startOperation, renderOperation, operationSucceeded, refreshStatus, renderReleases, updateReleaseControls, runComponent, runAction, toast, fieldMarkup, optionLabel, updateConfigStatus };'; const nodes = new Map(); const node = (id) => nodes.get(id) || nodes.set(id, { value: '', checked: false, disabled: false, textContent: '', hidden: false, className: '', innerHTML: '', classList: { toggle() {} }, addEventListener() {}, querySelector() { return null; }, querySelectorAll() { return []; } }).get(id); const fields = {}; const formControls = []; let confirmCalls = 0; +const confirmMessages = []; let fetchImpl = async () => ({ ok: true, json: async () => ({ configuration: context.__ui.state.config }) }); const context = { console, setTimeout: () => 0, clearTimeout() {}, confirm: () => { confirmCalls += 1; return true; }, sessionStorage: { values: {}, getItem(key) { return this.values[key] || ''; }, setItem(key, value) { this.values[key] = value; }, removeItem(key) { delete this.values[key]; } }, - window: { addEventListener() {}, confirm: () => { confirmCalls += 1; return true; } }, + window: { addEventListener() {}, confirm: (message) => { confirmCalls += 1; confirmMessages.push(message); return true; } }, document: { querySelector: (selector) => node(selector), querySelectorAll: (selector) => selector.includes('#config-form') ? formControls : [] }, FormData: class { constructor() {} entries() { return Object.entries(fields).filter(([key]) => !formControls.some((control) => control.name === key && control.disabled)); } }, URL, @@ -71,6 +72,34 @@ ui.setBusy(false); assert.equal(node('#install-release').disabled, true, 'setBus assert.equal(ui.operationSucceeded({ label: 'backup', state: 'succeeded', output: [] }), true, 'legacy ordinary success without phase is terminal'); assert.equal(ui.operationSucceeded({ label: 'restart grafana', state: 'succeeded' }), true, 'component restart is not panel restart verification'); +// Copy and accessibility polish (pre-0.6.0). +assert.equal(ui.optionLabel('BOAT_UNITS', 'us_customary'), 'US customary'); +assert.equal(ui.optionLabel('SIGNALK_MODE', 'docker'), 'Docker (built-in)'); +assert.equal(ui.optionLabel('BOAT_CHAT_FALLBACK_PROVIDER', ''), 'None'); +const selectMarkup = ui.fieldMarkup({ key: 'SIGNALK_MODE', label: 'Signal K mode', kind: 'select', choices: ['docker', 'existing'], description: 'Help text', read_only: false }, 'existing'); +assert.match(selectMarkup, /