Compare commits

...

27 Commits

Author SHA1 Message Date
l5y e27d5ab53c Add chat log entries for telemetry, position, and neighbor events (#408)
* Add telemetry and neighbor chat log events

* Refine chat log highlights for telemetry and position updates

* Add emoji prefixes to chat log events

* Fix telemetry highlights and emoji styling

* Remove italic chat copy and drop zero-valued highlights

* address style and formatting issues
2025-11-03 12:33:02 +01:00
l5y 6af272c01f Handle missing instance domain outside production (#405) 2025-10-31 12:36:53 +01:00
l5y 03e2fe6a72 Add tabbed chat panel with channel grouping (#404)
* feat: add tabbed chat panel with channel grouping

* Handle ISO-only chat timestamps in dashboard renderer

* Remove redundant chat channel tag
2025-10-31 12:24:17 +01:00
l5y 87b4cd79e7 Normalize numeric client roles using Meshtastic CLI enums (#402)
* Normalize firmware client roles using CLI enums

* Prioritize CLI role lookup before protobuf fallbacks
2025-10-31 11:43:48 +01:00
l5y d94d75e605 Ensure Docker images publish versioned tags (#403) 2025-10-31 11:43:30 +01:00
l5y c965d05229 Document environment configuration variables (#400)
* Document environment configuration variables

* Escape sed replacements when updating .env values
2025-10-31 11:08:06 +01:00
l5y ba80fac36c Document federation refresh cadence (#401) 2025-10-31 11:05:08 +01:00
l5y 3c2c7611ee docs: document prometheus metrics (#399) 2025-10-31 11:04:20 +01:00
Nic Jansma 49e0f39ca9 Config: Read PROM_REPORT_IDS from environment (#398) 2025-10-29 09:22:33 +01:00
KenADev 625df7982d feat: Mesh-Ingestor: Ability to provide already-existing interface instance (#395)
* feat: Mesh-Ingestor: Ability to provide already-existing interface instance

* Prevent Signal-Registration if not main thread (causes exception)

* fix redundant ternary operator

---------

Co-authored-by: Ken Ahr <ken.a.iphone@googlemail.com>
2025-10-26 20:47:23 +01:00
KenADev 8eeb13166b fix: Ingestor: Fix error for non-existing datetime.UTC reference (#396)
Co-authored-by: Ken Ahr <ken.a.iphone@googlemail.com>
2025-10-26 20:46:31 +01:00
l5y 80645990cb Chore: bump version to 0.5.4 (#388)
Co-authored-by: l5yth <d220195275+l5yth@users.noreply.github.com>
2025-10-19 10:36:09 +00:00
l5y 96a3bb86e9 Add telemetry formatting module and overlay metrics (#387) 2025-10-19 12:13:32 +02:00
l5y 6775de3cca Prune blank values from API responses (#386) 2025-10-18 20:16:14 +02:00
l5y 8143fbd8f7 Add full support to telemetry schema and API (#385)
* feat: auto-upgrade telemetry schema

* Ensure numeric metrics fallback to valid values

* Format data processing numeric metric lookup
2025-10-18 15:19:33 +02:00
l5y cf3949ef95 Respect PORT environment override (#384) 2025-10-18 13:01:48 +02:00
l5y 32d9da2865 Add instance selector dropdown for federation deployments (#382)
* Add instance selector for federation regions

* Avoid HTML insertion when seeding instance selector
2025-10-18 10:53:26 +02:00
l5y 61e8c92f62 Harden federation announcements (#381) 2025-10-18 10:38:28 +02:00
l5y d954df6294 Ensure private mode disables federation (#380) 2025-10-18 09:48:40 +02:00
l5y 30d535bd43 Ensure private mode disables chat messaging (#378) 2025-10-17 22:47:54 +02:00
l5y d06aa42ab2 Respect FEDERATION flag for federation endpoints (#379) 2025-10-17 22:47:41 +02:00
l5y 108fc93ca1 Expose PRIVATE environment configuration (#377) 2025-10-17 22:43:42 +02:00
l5y 427479c1e6 Fix frontend coverage export for Codecov (#376)
* fix: export frontend coverage for codecov

* Merge V8 file coverages across workers
2025-10-17 22:43:23 +02:00
l5y ee05f312e8 Restrict instance API to recent updates (#374) 2025-10-17 22:17:49 +02:00
l5y c4193e38dc Document and expose federation configuration (#375) 2025-10-17 22:17:32 +02:00
l5y cb9b081606 Chore: bump version to 0.5.3 (#372) 2025-10-17 19:47:18 +00:00
l5y cc8fec6d05 Align theme and info controls (#371)
* Align theme and info controls

* design tweaks
2025-10-17 19:27:14 +00:00
47 changed files with 5563 additions and 383 deletions
+9
View File
@@ -49,6 +49,12 @@ MAX_DISTANCE=42
# Matrix aliases (e.g. #meshtastic-berlin:matrix.org) will be linked via matrix.to automatically.
CONTACT_LINK='#potatomesh:dod.ngo'
# Enable or disable PotatoMesh federation features (1=enabled, 0=disabled)
FEDERATION=1
# Hide public mesh messages from unauthenticated visitors (1=hidden, 0=public)
PRIVATE=0
# =============================================================================
# ADVANCED SETTINGS
@@ -65,6 +71,9 @@ INSTANCE_DOMAIN=mesh.example.org
# Docker image architecture (linux-amd64, linux-arm64, linux-armv7)
POTATOMESH_IMAGE_ARCH=linux-amd64
# Docker image tag (use "latest" for the newest release or pin to vX.Y)
POTATOMESH_IMAGE_TAG=latest
# Docker Compose networking profile
# Leave unset for Linux hosts (default host networking).
# Set to "bridge" on Docker Desktop (macOS/Windows) if host networking
+16 -6
View File
@@ -56,12 +56,17 @@ jobs:
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
RAW_VERSION="${{ github.event.inputs.version }}"
else
VERSION=${GITHUB_REF#refs/tags/v}
RAW_VERSION=${GITHUB_REF#refs/tags/}
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Published version: $VERSION"
STRIPPED_VERSION=${RAW_VERSION#v}
echo "version=$STRIPPED_VERSION" >> $GITHUB_OUTPUT
echo "version_with_v=v$STRIPPED_VERSION" >> $GITHUB_OUTPUT
echo "raw_version=$RAW_VERSION" >> $GITHUB_OUTPUT
echo "Published version: $STRIPPED_VERSION"
- name: Build and push ${{ matrix.service }} for ${{ matrix.architecture.name }}
uses: docker/build-push-action@v5
@@ -74,6 +79,7 @@ jobs:
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service }}-${{ matrix.architecture.name }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service }}-${{ matrix.architecture.name }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-${{ matrix.service }}-${{ matrix.architecture.name }}:${{ steps.version.outputs.version_with_v }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || 'Python Ingestor' }} for ${{ matrix.architecture.label }}
@@ -111,12 +117,15 @@ jobs:
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
RAW_VERSION=${GITHUB_REF#refs/tags/}
STRIPPED_VERSION=${RAW_VERSION#v}
echo "version=$STRIPPED_VERSION" >> $GITHUB_OUTPUT
echo "version_with_v=v$STRIPPED_VERSION" >> $GITHUB_OUTPUT
- name: Test web application (Linux AMD64)
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-amd64:${{ steps.version.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web-linux-amd64:${{ steps.version.outputs.version_with_v }}
docker run --rm -d --name web-test -p 41447:41447 \
-e API_TOKEN=test-token \
-e DEBUG=1 \
@@ -128,6 +137,7 @@ jobs:
- name: Test ingestor (Linux AMD64)
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:${{ steps.version.outputs.version }}
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-amd64:${{ steps.version.outputs.version_with_v }}
docker run --rm --name ingestor-test \
-e POTATOMESH_INSTANCE=http://localhost:41447 \
-e API_TOKEN=test-token \
+33
View File
@@ -1,5 +1,38 @@
# CHANGELOG
## v0.5.3
* Add telemetry formatting utilities and extend node overlay by @l5yth in <https://github.com/l5yth/potato-mesh/pull/387>
* Prune blank values from API responses by @l5yth in <https://github.com/l5yth/potato-mesh/pull/386>
* Add full support to telemetry schema and API by @l5yth in <https://github.com/l5yth/potato-mesh/pull/385>
* Respect PORT environment override by @l5yth in <https://github.com/l5yth/potato-mesh/pull/384>
* Add instance selector dropdown for federation deployments by @l5yth in <https://github.com/l5yth/potato-mesh/pull/382>
* Harden federation announcements by @l5yth in <https://github.com/l5yth/potato-mesh/pull/381>
* Ensure private mode disables federation by @l5yth in <https://github.com/l5yth/potato-mesh/pull/380>
* Ensure private mode disables chat messaging by @l5yth in <https://github.com/l5yth/potato-mesh/pull/378>
* Disable federation features when FEDERATION=0 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/379>
* Expose PRIVATE environment configuration across tooling by @l5yth in <https://github.com/l5yth/potato-mesh/pull/377>
* Fix frontend coverage export for Codecov by @l5yth in <https://github.com/l5yth/potato-mesh/pull/376>
* Restrict /api/instances results to recent records by @l5yth in <https://github.com/l5yth/potato-mesh/pull/374>
* Expose FEDERATION environment option across tooling by @l5yth in <https://github.com/l5yth/potato-mesh/pull/375>
* Chore: bump version to 0.5.3 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/372>
## v0.5.2
* Align theme and info controls by @l5yth in <https://github.com/l5yth/potato-mesh/pull/371>
* Fixes POST request 403 errors on instances behind Cloudflare proxy by @varna9000 in <https://github.com/l5yth/potato-mesh/pull/368>
* Delay initial federation announcements by @l5yth in <https://github.com/l5yth/potato-mesh/pull/366>
* Ensure well-known document stays in sync on startup by @l5yth in <https://github.com/l5yth/potato-mesh/pull/365>
* Guard federation DNS resolution against restricted networks by @l5yth in <https://github.com/l5yth/potato-mesh/pull/362>
* Add federation ingestion limits and tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/364>
* Prefer reported primary channel names by @l5yth in <https://github.com/l5yth/potato-mesh/pull/363>
* Decouple message API node hydration by @l5yth in <https://github.com/l5yth/potato-mesh/pull/360>
* Fix ingestor reconnection detection by @l5yth in <https://github.com/l5yth/potato-mesh/pull/361>
* Harden instance domain validation by @l5yth in <https://github.com/l5yth/potato-mesh/pull/359>
* Ensure INSTANCE_DOMAIN propagates to containers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/358>
* Chore: bump version to 0.5.2 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/356>
* Gracefully retry federation announcements over HTTP by @l5yth in <https://github.com/l5yth/potato-mesh/pull/355>
## v0.5.1
* Recursively ingest federated instances by @l5yth in <https://github.com/l5yth/potato-mesh/pull/353>
+26 -17
View File
@@ -13,13 +13,15 @@ will pull the latest release images for you.
## Images on GHCR
| Service | Image |
|----------|-------------------------------------------------------------------|
| Web UI | `ghcr.io/l5yth/potato-mesh-web-linux-amd64:latest` |
| Ingestor | `ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:latest` |
| Service | Image |
|----------|---------------------------------------------------------------------------------------------------------------|
| Web UI | `ghcr.io/l5yth/potato-mesh-web-linux-amd64:<tag>` (e.g. `latest`, `3.0`, or `v3.0`) |
| Ingestor | `ghcr.io/l5yth/potato-mesh-ingestor-linux-amd64:<tag>` (e.g. `latest`, `3.0`, or `v3.0`) |
Images are published for every tagged release. Replace `latest` with a
specific version tag if you prefer pinned deployments.
Images are published for every tagged release. Each build receives both semantic
version tags (for example `3.0`) and a matching `v`-prefixed tag (for example
`v3.0`). `latest` always points to the newest release, so pin one of the version
tags when you need a specific build.
## Configure environment
@@ -36,17 +38,24 @@ INSTANCE_DOMAIN=mesh.example.org
Additional environment variables are optional:
- `CHANNEL`, `FREQUENCY`, `MAP_CENTER`, `MAX_DISTANCE`, and `CONTACT_LINK`
customise the UI.
- `POTATOMESH_INSTANCE` (defaults to `http://web:41447`) lets the ingestor post
to a remote PotatoMesh instance if you do not run both services together.
- `CONNECTION` overrides the default serial device or network endpoint used by
the ingestor.
- `CHANNEL_INDEX` selects the LoRa channel when using serial or Bluetooth
connections.
- `INSTANCE_DOMAIN` pins the public hostname advertised by the web UI and API
responses, bypassing reverse DNS detection when set.
- `DEBUG` enables verbose logging across the stack.
| Variable | Default | Purpose |
| --- | --- | --- |
| `API_TOKEN` | _required_ | Shared secret used by the ingestor and API clients for authenticated `POST` requests. |
| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) advertised by the web UI, metadata, and API responses. |
| `SITE_NAME` | `"PotatoMesh Demo"` | Title and branding surfaced in the web UI. |
| `CHANNEL` | `"#LongFast"` | Default LoRa channel label displayed on the dashboard. |
| `FREQUENCY` | `"915MHz"` | Default LoRa frequency description shown in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix room alias rendered in UI footers and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map view. |
| `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. |
| `DEBUG` | `0` | Enables verbose logging across services when set to `1`. |
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the radio. |
The ingestor also respects supporting variables such as `POTATOMESH_INSTANCE`
(defaults to `http://web:41447`) for remote posting and `CHANNEL_INDEX` when
selecting a LoRa channel on serial or Bluetooth connections.
## Docker Compose file
+100
View File
@@ -0,0 +1,100 @@
# Prometheus Monitoring for PotatoMesh
PotatoMesh exposes runtime telemetry through a dedicated Prometheus endpoint so you can
observe message flow, node health, and geospatial metadata alongside the rest of your
infrastructure. This guide explains how the exporter is wired into the web
application, which metrics are available, and how to integrate the endpoint with a
Prometheus server.
## Runtime integration
The Sinatra application automatically loads the `prometheus-client` gem and mounts the
collector and exporter middlewares during boot. No additional configuration is
required to enable the `/metrics` endpoint—running the web application is enough to
serve Prometheus data on the same port as the dashboard. The middleware pair both
collects default Rack statistics and publishes PotatoMesh-specific gauges and
counters that are updated whenever the ingestors process new node records.
A background refresh is triggered during start-up via
`update_all_prometheus_metrics_from_nodes`, which seeds the gauges based on the latest
state in the database. Subsequent POST requests to the ingest APIs update each metric
in near real time.
## Selecting which nodes are exported
To avoid creating high-cardinality time series, PotatoMesh does not export per-node
metrics unless you opt in by providing node identifiers. Control this behaviour with
the `PROM_REPORT_IDS` environment variable:
- Leave the variable unset or blank to only export aggregate gauges such as the total
node count.
- Set `PROM_REPORT_IDS=*` to export metrics for every node in the database.
- Provide a comma-separated list (for example `PROM_REPORT_IDS=ABCD1234,EFGH5678`) to
expose metrics for specific nodes.
The selection applies to both the initial refresh and the incremental updates handled
by the ingest pipeline.
## Available metrics
| Metric name | Type | Labels | Description |
| --- | --- | --- | --- |
| `meshtastic_messages_total` | Counter | _none_ | Increments each time the ingest pipeline accepts a new message payload. |
| `meshtastic_nodes` | Gauge | _none_ | Tracks the number of nodes currently stored in the database. |
| `meshtastic_node` | Gauge | `node`, `short_name`, `long_name`, `hw_model`, `role` | Reports a node as present (value `1`) along with identity metadata. |
| `meshtastic_node_battery_level` | Gauge | `node` | Most recent battery percentage reported by the node. |
| `meshtastic_node_voltage` | Gauge | `node` | Most recent battery voltage reading. |
| `meshtastic_node_uptime_seconds` | Gauge | `node` | Uptime reported by the device in seconds. |
| `meshtastic_node_channel_utilization` | Gauge | `node` | Latest channel utilisation ratio supplied by the node. |
| `meshtastic_node_transmit_air_utilization` | Gauge | `node` | Proportion of on-air time spent transmitting. |
| `meshtastic_node_latitude` | Gauge | `node` | Latitude component of the last known position. |
| `meshtastic_node_longitude` | Gauge | `node` | Longitude component of the last known position. |
| `meshtastic_node_altitude` | Gauge | `node` | Altitude (in metres) of the last known position. |
All per-node gauges are only emitted for identifiers included in `PROM_REPORT_IDS`.
Some values require telemetry packets to be present—for example, devices must provide
metrics or positional updates before the related gauges appear.
## Accessing the `/metrics` endpoint
Once the application is running, query the exporter directly:
```bash
curl http://localhost:41447/metrics
```
Use any HTTP client capable of plain-text requests. Prometheus scrapers should target
the same URL. The endpoint returns data in the standard exposition format produced by
`prometheus-client`.
## Prometheus scrape configuration
Add a job to your Prometheus server configuration that points to the PotatoMesh
instance. This example polls an instance running locally on the default port every 15
seconds:
```yaml
scrape_configs:
- job_name: potatomesh
scrape_interval: 15s
static_configs:
- targets:
- localhost:41447
```
If your deployment requires authentication or runs behind a reverse proxy, configure
Prometheus to match your network topology (for example by adding basic authentication
credentials, custom headers, or TLS settings).
## Troubleshooting
- **No per-node metrics appear.** Ensure that `PROM_REPORT_IDS` is set and that the
specified nodes exist in the database. Set the value to `*` if you want to export
every node during initial validation.
- **Metrics look stale after a restart.** Confirm that the ingestor is still posting
telemetry. The exporter only reflects data stored in the PotatoMesh database.
- **Scrapes time out.** Verify that the Prometheus server can reach the PotatoMesh
HTTP port and that no reverse proxy is blocking the `/metrics` path.
With the endpoint configured, you can build Grafana dashboards or alerting rules to
keep track of community mesh health in real time.
+42 -10
View File
@@ -70,14 +70,20 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
The web app can be configured with environment variables (defaults shown):
* `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo")
* `CHANNEL` - default channel shown in the UI (default: "#LongFast")
* `FREQUENCY` - default frequency shown in the UI (default: "915MHz")
* `MAP_CENTER` - default map center coordinates (default: `38.761944,-27.090833`)
* `MAX_DISTANCE` - hide nodes farther than this distance from the center (default: `42`)
* `CONTACT_LINK` - chat link or Matrix alias for footer and overlay (default: `#potatomesh:dod.ngo`)
* `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset)
* `INSTANCE_DOMAIN` - public hostname (optionally with port) used for metadata, federation, and API links (default: auto-detected)
| Variable | Default | Purpose |
| --- | --- | --- |
| `API_TOKEN` | _required_ | Shared secret that authorizes ingestors and API clients making `POST` requests. |
| `INSTANCE_DOMAIN` | _auto-detected_ | Public hostname (optionally with port) used for metadata, federation, and generated API links. |
| `SITE_NAME` | `"PotatoMesh Demo"` | Title and header displayed in the UI. |
| `CHANNEL` | `"#LongFast"` | Default channel name displayed in the UI. |
| `FREQUENCY` | `"915MHz"` | Default frequency description displayed in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix alias rendered in the footer and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map on load. |
| `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. |
| `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. |
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the Meshtastic radio. |
The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled
@@ -101,6 +107,23 @@ well-known document is staged in
The database can be found in `$XDG_DATA_HOME/potato-mesh`.
### Federation
PotatoMesh instances can optionally federate by publishing signed metadata and
discovering peers. Federation is enabled by default and controlled with the
`FEDERATION` environment variable. Set `FEDERATION=1` (default) to announce your
instance, respond to remote crawlers, and crawl the wider network. Set
`FEDERATION=0` to keep your deployment isolated—federation requests will be
ignored and the ingestor will skip discovery tasks. Private mode still takes
precedence; when `PRIVATE=1`, federation features remain disabled regardless of
the `FEDERATION` value.
When federation is enabled, PotatoMesh automatically refreshes entries from
known peers every eight hours to keep the directory current. Instances that
stop responding are considered stale and are removed from the web frontend after
72 hours, ensuring visitors only see active deployments in the public
directory.
### API
The web app contains an API:
@@ -121,6 +144,12 @@ The web app contains an API:
The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests.
### Observability
PotatoMesh ships with a Prometheus exporter mounted at `/metrics`. Consult
[`PROMETHEUS.md`](./PROMETHEUS.md) for deployment guidance, metric details, and
scrape configuration examples.
## Python Ingestor
The web app is not meant to be run locally connected to a Meshtastic node but rather
@@ -173,11 +202,14 @@ Post your nodes here:
Docker images are published on Github for each release:
```bash
docker pull ghcr.io/l5yth/potato-mesh/web:latest
docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release
docker pull ghcr.io/l5yth/potato-mesh/web:v3.0 # pinned historical release
docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest
```
See the [Docker guide](DOCKER.md) for more details and custome deployment instructions.
Set `POTATOMESH_IMAGE_TAG` in your `.env` (or environment) to deploy a specific
tagged release with Docker Compose. See the [Docker guide](DOCKER.md) for more
details and custom deployment instructions.
## License
+50 -2
View File
@@ -56,10 +56,14 @@ read_with_default() {
update_env() {
local key="$1"
local value="$2"
local escaped_value
# Escape characters that would break the sed replacement delimiter or introduce backreferences
escaped_value=$(printf '%s' "$value" | sed -e 's/[&|]/\\&/g')
if grep -q "^$key=" .env; then
# Update existing value
sed -i.bak "s/^$key=.*/$key=$value/" .env
sed -i.bak "s|^$key=.*|$key=$escaped_value|" .env
else
# Add new value
echo "$key=$value" >> .env
@@ -70,12 +74,17 @@ update_env() {
SITE_NAME=$(grep "^SITE_NAME=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "PotatoMesh Demo")
CHANNEL=$(grep "^CHANNEL=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#LongFast")
FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "915MHz")
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833")
MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42")
CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo")
API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
POTATOMESH_IMAGE_ARCH=$(grep "^POTATOMESH_IMAGE_ARCH=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "linux-amd64")
POTATOMESH_IMAGE_TAG=$(grep "^POTATOMESH_IMAGE_TAG=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "latest")
INSTANCE_DOMAIN=$(grep "^INSTANCE_DOMAIN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
DEBUG=$(grep "^DEBUG=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
CONNECTION=$(grep "^CONNECTION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "/dev/ttyACM0")
echo "📍 Location Settings"
echo "-------------------"
@@ -93,12 +102,37 @@ echo ""
echo "💬 Optional Settings"
echo "-------------------"
read_with_default "Chat link or Matrix room (optional)" "$CONTACT_LINK" CONTACT_LINK
read_with_default "Debug logging (1=enabled, 0=disabled)" "$DEBUG" DEBUG
echo ""
echo "🤝 Federation Settings"
echo "----------------------"
echo "Federation shares instance metadata with other PotatoMesh deployments."
echo "Set to 1 to enable discovery or 0 to keep your instance isolated."
read_with_default "Enable federation (1=yes, 0=no)" "$FEDERATION" FEDERATION
echo ""
echo "🙈 Privacy Settings"
echo "-------------------"
echo "Private mode hides public mesh messages from unauthenticated visitors."
echo "Set to 1 to hide public feeds or 0 to keep them visible."
read_with_default "Enable private mode (1=yes, 0=no)" "$PRIVATE" PRIVATE
echo ""
echo "🛠 Docker Settings"
echo "------------------"
echo "Specify the Docker image architecture for your host (linux-amd64, linux-arm64, linux-armv7)."
read_with_default "Docker image architecture" "$POTATOMESH_IMAGE_ARCH" POTATOMESH_IMAGE_ARCH
echo "Enter the Docker image tag to deploy (use 'latest' for the newest release or pin a version such as v3.0)."
read_with_default "Docker image tag (latest, vX.Y, etc.)" "$POTATOMESH_IMAGE_TAG" POTATOMESH_IMAGE_TAG
echo ""
echo "🔌 Ingestor Connection"
echo "----------------------"
echo "Define how the mesh ingestor connects to your Meshtastic device."
echo "Use serial devices like /dev/ttyACM0, TCP endpoints such as tcp://host:port,"
echo "or Bluetooth addresses when supported."
read_with_default "Connection target" "$CONNECTION" CONNECTION
echo ""
echo "🌐 Domain Settings"
@@ -148,8 +182,13 @@ update_env "FREQUENCY" "\"$FREQUENCY\""
update_env "MAP_CENTER" "\"$MAP_CENTER\""
update_env "MAX_DISTANCE" "$MAX_DISTANCE"
update_env "CONTACT_LINK" "\"$CONTACT_LINK\""
update_env "DEBUG" "$DEBUG"
update_env "API_TOKEN" "$API_TOKEN"
update_env "POTATOMESH_IMAGE_ARCH" "$POTATOMESH_IMAGE_ARCH"
update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG"
update_env "FEDERATION" "$FEDERATION"
update_env "PRIVATE" "$PRIVATE"
update_env "CONNECTION" "$CONNECTION"
if [ -n "$INSTANCE_DOMAIN" ]; then
update_env "INSTANCE_DOMAIN" "$INSTANCE_DOMAIN"
else
@@ -187,9 +226,18 @@ echo " Max Distance: ${MAX_DISTANCE}km"
echo " Channel: $CHANNEL"
echo " Frequency: $FREQUENCY"
echo " Chat: ${CONTACT_LINK:-'Not set'}"
echo " Debug Logging: ${DEBUG}"
echo " Connection: ${CONNECTION}"
echo " API Token: ${API_TOKEN:0:8}..."
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
echo " Docker Image Tag: $POTATOMESH_IMAGE_TAG"
echo " Private Mode: ${PRIVATE}"
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
if [ "${FEDERATION:-1}" = "0" ]; then
echo " Federation: Disabled"
else
echo " Federation: Enabled"
fi
echo ""
echo "🚀 You can now start PotatoMesh with:"
echo " docker-compose up -d"
+5 -4
View File
@@ -206,7 +206,7 @@ def _connected_state(candidate) -> bool | None:
return None
def main() -> None:
def main(existing_interface=None) -> None:
"""Run the mesh ingestion daemon until interrupted."""
subscribed = _subscribe_receive_topics()
@@ -218,7 +218,7 @@ def main() -> None:
topics=subscribed,
)
iface = None
iface = existing_interface
resolved_target = None
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
@@ -254,8 +254,9 @@ def main() -> None:
return
stop.set()
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
if threading.current_thread() == threading.main_thread():
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
target = config.INSTANCE or "(no POTATOMESH_INSTANCE)"
configured_port = config.CONNECTION
+219
View File
@@ -460,6 +460,189 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None:
)
)
current = _coerce_float(
_first(
telemetry_section,
"current",
"deviceMetrics.current",
"deviceMetrics.current_ma",
"deviceMetrics.currentMa",
"environmentMetrics.current",
default=None,
)
)
gas_resistance = _coerce_float(
_first(
telemetry_section,
"gasResistance",
"gas_resistance",
"environmentMetrics.gasResistance",
"environmentMetrics.gas_resistance",
default=None,
)
)
iaq = _coerce_int(
_first(
telemetry_section,
"iaq",
"environmentMetrics.iaq",
"environmentMetrics.iaqIndex",
"environmentMetrics.iaq_index",
default=None,
)
)
distance = _coerce_float(
_first(
telemetry_section,
"distance",
"environmentMetrics.distance",
"environmentMetrics.range",
"environmentMetrics.rangeMeters",
default=None,
)
)
lux = _coerce_float(
_first(
telemetry_section,
"lux",
"environmentMetrics.lux",
"environmentMetrics.illuminance",
default=None,
)
)
white_lux = _coerce_float(
_first(
telemetry_section,
"whiteLux",
"white_lux",
"environmentMetrics.whiteLux",
"environmentMetrics.white_lux",
default=None,
)
)
ir_lux = _coerce_float(
_first(
telemetry_section,
"irLux",
"ir_lux",
"environmentMetrics.irLux",
"environmentMetrics.ir_lux",
default=None,
)
)
uv_lux = _coerce_float(
_first(
telemetry_section,
"uvLux",
"uv_lux",
"environmentMetrics.uvLux",
"environmentMetrics.uv_lux",
"environmentMetrics.uvIndex",
default=None,
)
)
wind_direction = _coerce_int(
_first(
telemetry_section,
"windDirection",
"wind_direction",
"environmentMetrics.windDirection",
"environmentMetrics.wind_direction",
default=None,
)
)
wind_speed = _coerce_float(
_first(
telemetry_section,
"windSpeed",
"wind_speed",
"environmentMetrics.windSpeed",
"environmentMetrics.wind_speed",
"environmentMetrics.windSpeedMps",
default=None,
)
)
wind_gust = _coerce_float(
_first(
telemetry_section,
"windGust",
"wind_gust",
"environmentMetrics.windGust",
"environmentMetrics.wind_gust",
default=None,
)
)
wind_lull = _coerce_float(
_first(
telemetry_section,
"windLull",
"wind_lull",
"environmentMetrics.windLull",
"environmentMetrics.wind_lull",
default=None,
)
)
weight = _coerce_float(
_first(
telemetry_section,
"weight",
"environmentMetrics.weight",
"environmentMetrics.mass",
default=None,
)
)
radiation = _coerce_float(
_first(
telemetry_section,
"radiation",
"environmentMetrics.radiation",
"environmentMetrics.radiationLevel",
default=None,
)
)
rainfall_1h = _coerce_float(
_first(
telemetry_section,
"rainfall1h",
"rainfall_1h",
"environmentMetrics.rainfall1h",
"environmentMetrics.rainfall_1h",
"environmentMetrics.rainfallOneHour",
default=None,
)
)
rainfall_24h = _coerce_float(
_first(
telemetry_section,
"rainfall24h",
"rainfall_24h",
"environmentMetrics.rainfall24h",
"environmentMetrics.rainfall_24h",
"environmentMetrics.rainfallTwentyFourHour",
default=None,
)
)
soil_moisture = _coerce_int(
_first(
telemetry_section,
"soilMoisture",
"soil_moisture",
"environmentMetrics.soilMoisture",
"environmentMetrics.soil_moisture",
default=None,
)
)
soil_temperature = _coerce_float(
_first(
telemetry_section,
"soilTemperature",
"soil_temperature",
"environmentMetrics.soilTemperature",
"environmentMetrics.soil_temperature",
default=None,
)
)
telemetry_payload = {
"id": pkt_id,
"node_id": node_id,
@@ -494,6 +677,42 @@ def store_telemetry_packet(packet: Mapping, decoded: Mapping) -> None:
telemetry_payload["relative_humidity"] = relative_humidity
if barometric_pressure is not None:
telemetry_payload["barometric_pressure"] = barometric_pressure
if current is not None:
telemetry_payload["current"] = current
if gas_resistance is not None:
telemetry_payload["gas_resistance"] = gas_resistance
if iaq is not None:
telemetry_payload["iaq"] = iaq
if distance is not None:
telemetry_payload["distance"] = distance
if lux is not None:
telemetry_payload["lux"] = lux
if white_lux is not None:
telemetry_payload["white_lux"] = white_lux
if ir_lux is not None:
telemetry_payload["ir_lux"] = ir_lux
if uv_lux is not None:
telemetry_payload["uv_lux"] = uv_lux
if wind_direction is not None:
telemetry_payload["wind_direction"] = wind_direction
if wind_speed is not None:
telemetry_payload["wind_speed"] = wind_speed
if wind_gust is not None:
telemetry_payload["wind_gust"] = wind_gust
if wind_lull is not None:
telemetry_payload["wind_lull"] = wind_lull
if weight is not None:
telemetry_payload["weight"] = weight
if radiation is not None:
telemetry_payload["radiation"] = radiation
if rainfall_1h is not None:
telemetry_payload["rainfall_1h"] = rainfall_1h
if rainfall_24h is not None:
telemetry_payload["rainfall_24h"] = rainfall_24h
if soil_moisture is not None:
telemetry_payload["soil_moisture"] = soil_moisture
if soil_temperature is not None:
telemetry_payload["soil_temperature"] = soil_temperature
_queue_post_json(
"/api/telemetry",
+164 -1
View File
@@ -22,6 +22,8 @@ from __future__ import annotations
import base64
import dataclasses
import enum
import importlib
import json
import math
import time
@@ -31,6 +33,18 @@ from google.protobuf.json_format import MessageToDict
from google.protobuf.message import DecodeError
from google.protobuf.message import Message as ProtoMessage
_CLI_ROLE_MODULE_NAMES: tuple[str, ...] = (
"meshtastic.cli.common",
"meshtastic.cli.roles",
"meshtastic.cli.enums",
"meshtastic_cli.common",
"meshtastic_cli.roles",
)
"""Possible module paths that may expose the Meshtastic CLI role enum."""
_CLI_ROLE_LOOKUP: dict[int, str] | None = None
"""Cached mapping of CLI role identifiers to their textual names."""
def _get(obj, key, default=None):
"""Return ``obj[key]`` or ``getattr(obj, key)`` when available.
@@ -49,6 +63,96 @@ def _get(obj, key, default=None):
return getattr(obj, key, default)
def _reset_cli_role_cache() -> None:
"""Clear the cached CLI role lookup mapping.
The helper is primarily used by tests to ensure deterministic behaviour
when substituting stub CLI modules.
Returns:
``None``. The next lookup will trigger a fresh import attempt.
"""
global _CLI_ROLE_LOOKUP
_CLI_ROLE_LOOKUP = None
def _load_cli_role_lookup() -> dict[int, str]:
"""Return a mapping of role identifiers from the Meshtastic CLI.
The Meshtastic CLI exposes extended role enums that may include entries
absent from the protobuf definition shipped with the firmware. This
helper lazily imports the CLI module when present and extracts the
available role names so that numeric values received from the firmware can
be normalised into human-friendly strings.
Returns:
Mapping of integer role identifiers to their canonical string names.
"""
global _CLI_ROLE_LOOKUP
if _CLI_ROLE_LOOKUP is not None:
return _CLI_ROLE_LOOKUP
lookup: dict[int, str] = {}
def _from_candidate(candidate) -> dict[int, str]:
mapping: dict[int, str] = {}
if isinstance(candidate, enum.EnumMeta):
for member in candidate: # pragma: no branch - Enum iteration deterministic
try:
mapping[int(member.value)] = str(member.name)
except Exception: # pragma: no cover - defensive guard
continue
return mapping
members = getattr(candidate, "__members__", None)
if isinstance(members, Mapping):
for name, member in members.items():
value = getattr(member, "value", None)
if isinstance(value, (int, enum.IntEnum)):
try:
mapping[int(value)] = str(name)
except Exception: # pragma: no cover - defensive
continue
if mapping:
return mapping
if isinstance(candidate, Mapping):
for key, value in candidate.items():
try:
key_int = int(key)
except Exception: # pragma: no cover - defensive
continue
mapping[key_int] = str(value)
return mapping
for module_name in _CLI_ROLE_MODULE_NAMES:
try:
module = importlib.import_module(module_name)
except Exception: # pragma: no cover - optional dependency
continue
candidates = []
for attr_name in ("Role", "Roles", "ClientRole", "ClientRoles"):
candidate = getattr(module, attr_name, None)
if candidate is not None:
candidates.append(candidate)
for candidate in candidates:
mapping = _from_candidate(candidate)
if not mapping:
continue
lookup.update(mapping)
if lookup:
break
_CLI_ROLE_LOOKUP = {
key: value.strip().upper()
for key, value in lookup.items()
if isinstance(value, str) and value.strip()
}
return _CLI_ROLE_LOOKUP
def _node_to_dict(n) -> dict:
"""Convert ``n`` into a JSON-serialisable mapping.
@@ -99,6 +203,57 @@ def _node_to_dict(n) -> dict:
return _convert(n)
def _normalize_user_role(value) -> str | None:
"""Return a canonical role string for ``value`` when possible.
Parameters:
value: Raw role descriptor emitted by the Meshtastic firmware or
decoded JSON payloads.
Returns:
Uppercase role string or ``None`` if the value cannot be resolved.
"""
if value is None:
return None
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
return None
return cleaned.upper()
numeric = _coerce_int(value)
if numeric is None:
return None
role_name = None
cli_lookup = _load_cli_role_lookup()
role_name = cli_lookup.get(numeric)
if not role_name:
try: # pragma: no branch - minimal control flow
from meshtastic.protobuf import mesh_pb2
role_name = mesh_pb2.User.Role.Name(numeric)
except Exception: # pragma: no cover - depends on protobuf version
role_name = None
if not role_name:
try:
from meshtastic.protobuf import config_pb2
role_name = config_pb2.Config.DeviceConfig.Role.Name(numeric)
except Exception: # pragma: no cover - depends on protobuf version
role_name = None
if role_name:
return role_name.strip().upper()
return str(numeric)
def upsert_payload(node_id, node) -> dict:
"""Return the payload expected by ``/api/nodes`` upsert requests.
@@ -120,7 +275,7 @@ def _iso(ts: int | float) -> str:
import datetime
return (
datetime.datetime.fromtimestamp(int(ts), datetime.UTC)
datetime.datetime.fromtimestamp(int(ts), datetime.timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
@@ -587,6 +742,11 @@ def _nodeinfo_user_dict(node_info, decoded_user):
if canonical:
user_dict = dict(user_dict)
user_dict["id"] = canonical
role_value = user_dict.get("role")
normalized_role = _normalize_user_role(role_value)
if normalized_role and normalized_role != role_value:
user_dict = dict(user_dict)
user_dict["role"] = normalized_role
return user_dict
@@ -594,6 +754,8 @@ __all__ = [
"_canonical_node_id",
"_coerce_float",
"_coerce_int",
"_load_cli_role_lookup",
"_normalize_user_role",
"_decode_nodeinfo_payload",
"_extract_payload_bytes",
"_first",
@@ -606,6 +768,7 @@ __all__ = [
"_nodeinfo_position_dict",
"_nodeinfo_user_dict",
"_pkt_to_dict",
"_reset_cli_role_cache",
"DecodeError",
"MessageToDict",
"ProtoMessage",
@@ -0,0 +1,35 @@
-- Copyright (C) 2025 l5yth
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Extend the telemetry table with additional environment metrics.
BEGIN;
ALTER TABLE telemetry ADD COLUMN gas_resistance REAL;
ALTER TABLE telemetry ADD COLUMN current REAL;
ALTER TABLE telemetry ADD COLUMN iaq INTEGER;
ALTER TABLE telemetry ADD COLUMN distance REAL;
ALTER TABLE telemetry ADD COLUMN lux REAL;
ALTER TABLE telemetry ADD COLUMN white_lux REAL;
ALTER TABLE telemetry ADD COLUMN ir_lux REAL;
ALTER TABLE telemetry ADD COLUMN uv_lux REAL;
ALTER TABLE telemetry ADD COLUMN wind_direction INTEGER;
ALTER TABLE telemetry ADD COLUMN wind_speed REAL;
ALTER TABLE telemetry ADD COLUMN weight REAL;
ALTER TABLE telemetry ADD COLUMN wind_gust REAL;
ALTER TABLE telemetry ADD COLUMN wind_lull REAL;
ALTER TABLE telemetry ADD COLUMN radiation REAL;
ALTER TABLE telemetry ADD COLUMN rainfall_1h REAL;
ALTER TABLE telemetry ADD COLUMN rainfall_24h REAL;
ALTER TABLE telemetry ADD COLUMN soil_moisture INTEGER;
ALTER TABLE telemetry ADD COLUMN soil_temperature REAL;
COMMIT;
+19 -1
View File
@@ -35,7 +35,25 @@ CREATE TABLE IF NOT EXISTS telemetry (
uptime_seconds INTEGER,
temperature REAL,
relative_humidity REAL,
barometric_pressure REAL
barometric_pressure REAL,
gas_resistance REAL,
current REAL,
iaq INTEGER,
distance REAL,
lux REAL,
white_lux REAL,
ir_lux REAL,
uv_lux REAL,
wind_direction INTEGER,
wind_speed REAL,
weight REAL,
wind_gust REAL,
wind_lull REAL,
radiation REAL,
rainfall_1h REAL,
rainfall_24h REAL,
soil_moisture INTEGER,
soil_temperature REAL
);
CREATE INDEX IF NOT EXISTS idx_telemetry_rx_time ON telemetry(rx_time);
+6 -2
View File
@@ -1,5 +1,5 @@
x-web-base: &web-base
image: ghcr.io/l5yth/potato-mesh-web-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:latest
image: ghcr.io/l5yth/potato-mesh-web-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
environment:
APP_ENV: ${APP_ENV:-production}
RACK_ENV: ${RACK_ENV:-production}
@@ -9,6 +9,8 @@ x-web-base: &web-base
MAP_CENTER: ${MAP_CENTER:-38.761944,-27.090833}
MAX_DISTANCE: ${MAX_DISTANCE:-42}
CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo}
FEDERATION: ${FEDERATION:-1}
PRIVATE: ${PRIVATE:-0}
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
DEBUG: ${DEBUG:-0}
@@ -28,7 +30,7 @@ x-web-base: &web-base
cpus: '0.25'
x-ingestor-base: &ingestor-base
image: ghcr.io/l5yth/potato-mesh-ingestor-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:latest
image: ghcr.io/l5yth/potato-mesh-ingestor-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
environment:
CONNECTION: ${CONNECTION:-/dev/ttyACM0}
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
@@ -36,6 +38,8 @@ x-ingestor-base: &ingestor-base
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
DEBUG: ${DEBUG:-0}
FEDERATION: ${FEDERATION:-1}
PRIVATE: ${PRIVATE:-0}
volumes:
- potatomesh_data:/app/.local/share/potato-mesh
- potatomesh_config:/app/.config/potato-mesh
+54 -3
View File
@@ -12,12 +12,31 @@
"battery_level": 101,
"bitfield": 1,
"payload_b64": "DTVr0mgSFQhlFQIrh0AdJb8YPyXYFSA9KJTPEg==",
"current": 0.0715,
"gas_resistance": 1456.0,
"iaq": 83,
"distance": 12.5,
"lux": 100.25,
"white_lux": 64.5,
"ir_lux": 12.75,
"uv_lux": 1.6,
"wind_direction": 270,
"wind_speed": 5.9,
"wind_gust": 7.4,
"wind_lull": 4.8,
"weight": 32.7,
"radiation": 0.45,
"rainfall_1h": 0.18,
"rainfall_24h": 1.42,
"soil_moisture": 3100,
"soil_temperature": 18.9,
"device_metrics": {
"batteryLevel": 101,
"voltage": 4.224,
"channelUtilization": 0.59666663,
"airUtilTx": 0.03908333,
"uptimeSeconds": 305044
"uptimeSeconds": 305044,
"current": 0.0715
},
"raw": {
"device_metrics": {
@@ -43,7 +62,24 @@
"environment_metrics": {
"temperature": 21.98,
"relativeHumidity": 39.475586,
"barometricPressure": 1017.8353
"barometricPressure": 1017.8353,
"gasResistance": 1456.0,
"iaq": 83,
"distance": 12.5,
"lux": 100.25,
"whiteLux": 64.5,
"irLux": 12.75,
"uvLux": 1.6,
"windDirection": 270,
"windSpeed": 5.9,
"windGust": 7.4,
"windLull": 4.8,
"weight": 32.7,
"radiation": 0.45,
"rainfall1h": 0.18,
"rainfall24h": 1.42,
"soilMoisture": 3100,
"soilTemperature": 18.9
},
"raw": {
"environment_metrics": {
@@ -70,7 +106,22 @@
"voltage": 3.92,
"channel_utilization": 0.284,
"air_util_tx": 0.051,
"uptime_seconds": 86400
"uptime_seconds": 86400,
"current": 0.033
},
"environment_metrics": {
"temperature": 19.5,
"relative_humidity": 48.2,
"barometric_pressure": 1013.1,
"distance": 7.25,
"lux": 75.5,
"whiteLux": 40.0,
"windDirection": 180,
"windSpeed": 4.3,
"weight": 28.4,
"rainfall24h": 0.75,
"soilMoisture": 2850,
"soilTemperature": 17.1
},
"local_stats": {
"numPacketsTx": 1280,
+75
View File
@@ -13,6 +13,7 @@
# limitations under the License.
import base64
import enum
import importlib
import re
import sys
@@ -1575,6 +1576,7 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
"channelUtilization": 0.59666663,
"airUtilTx": 0.03908333,
"uptimeSeconds": 305044,
"current": 0.0715,
},
"localStats": {
"numPacketsTx": 1280,
@@ -1606,6 +1608,7 @@ def test_store_packet_dict_handles_telemetry_packet(mesh_module, monkeypatch):
assert payload["channel_utilization"] == pytest.approx(0.59666663)
assert payload["air_util_tx"] == pytest.approx(0.03908333)
assert payload["uptime_seconds"] == 305044
assert payload["current"] == pytest.approx(0.0715)
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
@@ -1634,6 +1637,23 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc
"temperature": 21.98,
"relativeHumidity": 39.475586,
"barometricPressure": 1017.8353,
"gasResistance": 1456.0,
"iaq": 83,
"distance": 12.5,
"lux": 100.25,
"whiteLux": 64.5,
"irLux": 12.75,
"uvLux": 1.6,
"windDirection": 270,
"windSpeed": 5.9,
"windGust": 7.4,
"windLull": 4.8,
"weight": 32.7,
"radiation": 0.45,
"rainfall1h": 0.18,
"rainfall24h": 1.42,
"soilMoisture": 3100,
"soilTemperature": 18.9,
},
},
},
@@ -1651,6 +1671,23 @@ def test_store_packet_dict_handles_environment_telemetry(mesh_module, monkeypatc
assert payload["temperature"] == pytest.approx(21.98)
assert payload["relative_humidity"] == pytest.approx(39.475586)
assert payload["barometric_pressure"] == pytest.approx(1017.8353)
assert payload["gas_resistance"] == pytest.approx(1456.0)
assert payload["iaq"] == 83
assert payload["distance"] == pytest.approx(12.5)
assert payload["lux"] == pytest.approx(100.25)
assert payload["white_lux"] == pytest.approx(64.5)
assert payload["ir_lux"] == pytest.approx(12.75)
assert payload["uv_lux"] == pytest.approx(1.6)
assert payload["wind_direction"] == 270
assert payload["wind_speed"] == pytest.approx(5.9)
assert payload["wind_gust"] == pytest.approx(7.4)
assert payload["wind_lull"] == pytest.approx(4.8)
assert payload["weight"] == pytest.approx(32.7)
assert payload["radiation"] == pytest.approx(0.45)
assert payload["rainfall_1h"] == pytest.approx(0.18)
assert payload["rainfall_24h"] == pytest.approx(1.42)
assert payload["soil_moisture"] == 3100
assert payload["soil_temperature"] == pytest.approx(18.9)
assert payload["lora_freq"] == 868
assert payload["modem_preset"] == "MediumFast"
@@ -2155,6 +2192,44 @@ def test_nodeinfo_helpers_cover_fallbacks(mesh_module, monkeypatch):
assert user["id"] == "!11223344"
def test_nodeinfo_user_role_falls_back_to_cli_enum(mesh_module, monkeypatch):
mesh = mesh_module
mesh._reset_cli_role_cache()
cli_module = types.ModuleType("meshtastic.cli")
cli_common = types.ModuleType("meshtastic.cli.common")
class DummyRole(enum.IntEnum):
CLIENT = 0
CLIENT_BASE = 12
cli_common.Role = DummyRole
cli_module.common = cli_common
monkeypatch.setitem(sys.modules, "meshtastic.cli", cli_module)
monkeypatch.setitem(sys.modules, "meshtastic.cli.common", cli_common)
user = mesh._nodeinfo_user_dict(None, {"id": "!11223344", "role": 12})
assert user["role"] == "CLIENT_BASE"
mesh._reset_cli_role_cache()
cli_dict_module = types.ModuleType("meshtastic.cli")
cli_dict_common = types.ModuleType("meshtastic.cli.common")
cli_dict_common.ClientRoles = {12: "client_hidden"}
cli_dict_module.common = cli_dict_common
monkeypatch.setitem(sys.modules, "meshtastic.cli", cli_dict_module)
monkeypatch.setitem(sys.modules, "meshtastic.cli.common", cli_dict_common)
user = mesh._nodeinfo_user_dict(None, {"id": "!11223344", "role": 12})
assert user["role"] == "CLIENT_HIDDEN"
mesh._reset_cli_role_cache()
def test_store_position_packet_defaults(mesh_module, monkeypatch):
mesh = mesh_module
captured = []
+21 -3
View File
@@ -100,12 +100,30 @@ module PotatoMesh
logger.level = PotatoMesh::Config.debug? ? Logger::DEBUG : Logger::WARN
end
# Determine the port the application should listen on.
# Determine the port the application should listen on by honouring the
# conventional +PORT+ environment variable used by hosting platforms. Any
# non-numeric or out-of-range values fall back to the provided default to
# keep the application bootable in misconfigured environments.
#
# @param default_port [Integer] fallback port when ENV['PORT'] is absent or invalid.
# @param default_port [Integer] fallback port when +ENV['PORT']+ is absent or invalid.
# @return [Integer] port number for the HTTP server.
def self.resolve_port(default_port: DEFAULT_PORT)
default_port
raw_port = ENV["PORT"]
return default_port if raw_port.nil?
trimmed = raw_port.to_s.strip
return default_port if trimmed.empty?
begin
port = Integer(trimmed, 10)
rescue ArgumentError
return default_port
end
return default_port unless port.positive?
return default_port unless PotatoMesh::Sanitizer.valid_port?(trimmed)
port
end
configure do
@@ -731,6 +731,50 @@ module PotatoMesh
end
end
# Resolve a telemetry metric from the provided data sources.
#
# @param key_map [Hash{Symbol=>Array<String>}] ordered mapping of source names to candidate keys.
# @param sources [Hash{Symbol=>Hash}] data structures to search for metric values.
# @param type [Symbol] coercion strategy, ``:float`` or ``:integer``.
# @return [Numeric, nil] coerced metric value or nil when no candidates exist.
def resolve_numeric_metric(key_map, sources, type)
key_map.each do |source, keys|
next if keys.nil? || keys.empty?
data = sources[source]
next unless data.is_a?(Hash)
keys.each do |name|
next if name.nil?
key = name.to_s
value = if data.key?(key)
data[key]
else
sym_key = key.to_sym
data.key?(sym_key) ? data[sym_key] : nil
end
next if value.nil?
coerced = case type
when :float
coerce_float(value)
when :integer
coerce_integer(value)
else
value
end
return coerced unless coerced.nil?
end
end
nil
end
private :resolve_numeric_metric
def insert_telemetry(db, payload)
return unless payload.is_a?(Hash)
@@ -776,54 +820,285 @@ module PotatoMesh
environment_metrics = normalize_json_object(payload["environment_metrics"] || payload["environmentMetrics"])
environment_metrics ||= normalize_json_object(telemetry_section["environmentMetrics"]) if telemetry_section&.key?("environmentMetrics")
fetch_metric = lambda do |map, *names|
next nil unless map.is_a?(Hash)
names.each do |name|
next unless name
key = name.to_s
return map[key] if map.key?(key)
end
nil
sources = {
payload: payload,
telemetry: telemetry_section,
device: device_metrics,
environment: environment_metrics,
}
metric_definitions = [
[
"battery_level",
:float,
{
payload: %w[battery_level batteryLevel],
telemetry: %w[batteryLevel],
device: %w[battery_level batteryLevel],
environment: %w[battery_level batteryLevel],
},
],
[
"voltage",
:float,
{
payload: %w[voltage],
telemetry: %w[voltage],
device: %w[voltage],
environment: %w[voltage],
},
],
[
"channel_utilization",
:float,
{
payload: %w[channel_utilization channelUtilization],
telemetry: %w[channelUtilization],
device: %w[channel_utilization channelUtilization],
},
],
[
"air_util_tx",
:float,
{
payload: %w[air_util_tx airUtilTx],
telemetry: %w[airUtilTx],
device: %w[air_util_tx airUtilTx],
},
],
[
"uptime_seconds",
:integer,
{
payload: %w[uptime_seconds uptimeSeconds],
telemetry: %w[uptimeSeconds],
device: %w[uptime_seconds uptimeSeconds],
},
],
[
"temperature",
:float,
{
payload: %w[temperature temperatureC tempC],
telemetry: %w[temperature temperatureC tempC],
environment: %w[temperature temperatureC temperature_c tempC],
},
],
[
"relative_humidity",
:float,
{
payload: %w[relative_humidity relativeHumidity humidity],
telemetry: %w[relative_humidity relativeHumidity humidity],
environment: %w[relative_humidity relativeHumidity humidity],
},
],
[
"barometric_pressure",
:float,
{
payload: %w[barometric_pressure barometricPressure pressure],
telemetry: %w[barometric_pressure barometricPressure pressure],
environment: %w[barometric_pressure barometricPressure pressure],
},
],
[
"gas_resistance",
:float,
{
payload: %w[gas_resistance gasResistance],
telemetry: %w[gas_resistance gasResistance],
environment: %w[gas_resistance gasResistance],
},
],
[
"current",
:float,
{
payload: %w[current current_ma currentMa],
telemetry: %w[current current_ma currentMa],
device: %w[current current_ma currentMa],
environment: %w[current],
},
],
[
"iaq",
:integer,
{
payload: %w[iaq iaqIndex iaq_index],
telemetry: %w[iaq iaqIndex iaq_index],
environment: %w[iaq iaqIndex iaq_index],
},
],
[
"distance",
:float,
{
payload: %w[distance range rangeMeters],
telemetry: %w[distance range rangeMeters],
environment: %w[distance range rangeMeters],
},
],
[
"lux",
:float,
{
payload: %w[lux illuminance lightLux],
telemetry: %w[lux illuminance lightLux],
environment: %w[lux illuminance lightLux],
},
],
[
"white_lux",
:float,
{
payload: %w[white_lux whiteLux],
telemetry: %w[white_lux whiteLux],
environment: %w[white_lux whiteLux],
},
],
[
"ir_lux",
:float,
{
payload: %w[ir_lux irLux],
telemetry: %w[ir_lux irLux],
environment: %w[ir_lux irLux],
},
],
[
"uv_lux",
:float,
{
payload: %w[uv_lux uvLux uvIndex],
telemetry: %w[uv_lux uvLux uvIndex],
environment: %w[uv_lux uvLux uvIndex],
},
],
[
"wind_direction",
:integer,
{
payload: %w[wind_direction windDirection],
telemetry: %w[wind_direction windDirection],
environment: %w[wind_direction windDirection],
},
],
[
"wind_speed",
:float,
{
payload: %w[wind_speed windSpeed windSpeedMps],
telemetry: %w[wind_speed windSpeed windSpeedMps],
environment: %w[wind_speed windSpeed windSpeedMps],
},
],
[
"weight",
:float,
{
payload: %w[weight mass],
telemetry: %w[weight mass],
environment: %w[weight mass],
},
],
[
"wind_gust",
:float,
{
payload: %w[wind_gust windGust],
telemetry: %w[wind_gust windGust],
environment: %w[wind_gust windGust],
},
],
[
"wind_lull",
:float,
{
payload: %w[wind_lull windLull],
telemetry: %w[wind_lull windLull],
environment: %w[wind_lull windLull],
},
],
[
"radiation",
:float,
{
payload: %w[radiation radiationLevel],
telemetry: %w[radiation radiationLevel],
environment: %w[radiation radiationLevel],
},
],
[
"rainfall_1h",
:float,
{
payload: %w[rainfall_1h rainfall1h rainfallOneHour],
telemetry: %w[rainfall_1h rainfall1h rainfallOneHour],
environment: %w[rainfall_1h rainfall1h rainfallOneHour],
},
],
[
"rainfall_24h",
:float,
{
payload: %w[rainfall_24h rainfall24h rainfallTwentyFourHour],
telemetry: %w[rainfall_24h rainfall24h rainfallTwentyFourHour],
environment: %w[rainfall_24h rainfall24h rainfallTwentyFourHour],
},
],
[
"soil_moisture",
:integer,
{
payload: %w[soil_moisture soilMoisture],
telemetry: %w[soil_moisture soilMoisture],
environment: %w[soil_moisture soilMoisture],
},
],
[
"soil_temperature",
:float,
{
payload: %w[soil_temperature soilTemperature],
telemetry: %w[soil_temperature soilTemperature],
environment: %w[soil_temperature soilTemperature],
},
],
]
metric_values = {}
metric_definitions.each do |column, type, key_map|
value = resolve_numeric_metric(key_map, sources, type)
metric_values[column] = value unless value.nil?
end
battery_level = payload.key?("battery_level") ? payload["battery_level"] : nil
battery_level = coerce_float(battery_level)
battery_level ||= coerce_float(fetch_metric.call(device_metrics, :battery_level, :batteryLevel))
voltage = payload.key?("voltage") ? payload["voltage"] : nil
voltage = coerce_float(voltage)
voltage ||= coerce_float(fetch_metric.call(device_metrics, :voltage))
channel_utilization = payload.key?("channel_utilization") ? payload["channel_utilization"] : nil
channel_utilization ||= payload["channelUtilization"] if payload.key?("channelUtilization")
channel_utilization = coerce_float(channel_utilization)
channel_utilization ||= coerce_float(fetch_metric.call(device_metrics, :channel_utilization, :channelUtilization))
air_util_tx = payload.key?("air_util_tx") ? payload["air_util_tx"] : nil
air_util_tx ||= payload["airUtilTx"] if payload.key?("airUtilTx")
air_util_tx = coerce_float(air_util_tx)
air_util_tx ||= coerce_float(fetch_metric.call(device_metrics, :air_util_tx, :airUtilTx))
uptime_seconds = payload.key?("uptime_seconds") ? payload["uptime_seconds"] : nil
uptime_seconds ||= payload["uptimeSeconds"] if payload.key?("uptimeSeconds")
uptime_seconds = coerce_integer(uptime_seconds)
uptime_seconds ||= coerce_integer(fetch_metric.call(device_metrics, :uptime_seconds, :uptimeSeconds))
temperature = payload.key?("temperature") ? payload["temperature"] : nil
temperature = coerce_float(temperature)
temperature ||= coerce_float(fetch_metric.call(environment_metrics, :temperature, :temperatureC, :temperature_c, :tempC))
relative_humidity = payload.key?("relative_humidity") ? payload["relative_humidity"] : nil
relative_humidity ||= payload["relativeHumidity"] if payload.key?("relativeHumidity")
relative_humidity ||= payload["humidity"] if payload.key?("humidity")
relative_humidity = coerce_float(relative_humidity)
relative_humidity ||= coerce_float(fetch_metric.call(environment_metrics, :relative_humidity, :relativeHumidity, :humidity))
barometric_pressure = payload.key?("barometric_pressure") ? payload["barometric_pressure"] : nil
barometric_pressure ||= payload["barometricPressure"] if payload.key?("barometricPressure")
barometric_pressure ||= payload["pressure"] if payload.key?("pressure")
barometric_pressure = coerce_float(barometric_pressure)
barometric_pressure ||= coerce_float(fetch_metric.call(environment_metrics, :barometric_pressure, :barometricPressure, :pressure))
battery_level = metric_values["battery_level"]
voltage = metric_values["voltage"]
channel_utilization = metric_values["channel_utilization"]
air_util_tx = metric_values["air_util_tx"]
uptime_seconds = metric_values["uptime_seconds"]
temperature = metric_values["temperature"]
relative_humidity = metric_values["relative_humidity"]
barometric_pressure = metric_values["barometric_pressure"]
gas_resistance = metric_values["gas_resistance"]
current = metric_values["current"]
iaq = metric_values["iaq"]
distance = metric_values["distance"]
lux = metric_values["lux"]
white_lux = metric_values["white_lux"]
ir_lux = metric_values["ir_lux"]
uv_lux = metric_values["uv_lux"]
wind_direction = metric_values["wind_direction"]
wind_speed = metric_values["wind_speed"]
weight = metric_values["weight"]
wind_gust = metric_values["wind_gust"]
wind_lull = metric_values["wind_lull"]
radiation = metric_values["radiation"]
rainfall_1h = metric_values["rainfall_1h"]
rainfall_24h = metric_values["rainfall_24h"]
soil_moisture = metric_values["soil_moisture"]
soil_temperature = metric_values["soil_temperature"]
row = [
telemetry_id,
@@ -849,13 +1124,33 @@ module PotatoMesh
temperature,
relative_humidity,
barometric_pressure,
gas_resistance,
current,
iaq,
distance,
lux,
white_lux,
ir_lux,
uv_lux,
wind_direction,
wind_speed,
weight,
wind_gust,
wind_lull,
radiation,
rainfall_1h,
rainfall_24h,
soil_moisture,
soil_temperature,
]
placeholders = Array.new(row.length, "?").join(",")
with_busy_retry do
db.execute <<~SQL, row
INSERT INTO telemetry(id,node_id,node_num,from_id,to_id,rx_time,rx_iso,telemetry_time,channel,portnum,hop_limit,snr,rssi,bitfield,payload_b64,
battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,temperature,relative_humidity,barometric_pressure)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
battery_level,voltage,channel_utilization,air_util_tx,uptime_seconds,temperature,relative_humidity,barometric_pressure,gas_resistance,current,iaq,distance,lux,white_lux,ir_lux,uv_lux,wind_direction,wind_speed,weight,wind_gust,wind_lull,radiation,rainfall_1h,rainfall_24h,soil_moisture,soil_temperature)
VALUES (#{placeholders})
ON CONFLICT(id) DO UPDATE SET
node_id=COALESCE(excluded.node_id,telemetry.node_id),
node_num=COALESCE(excluded.node_num,telemetry.node_num),
@@ -878,7 +1173,25 @@ module PotatoMesh
uptime_seconds=COALESCE(excluded.uptime_seconds,telemetry.uptime_seconds),
temperature=COALESCE(excluded.temperature,telemetry.temperature),
relative_humidity=COALESCE(excluded.relative_humidity,telemetry.relative_humidity),
barometric_pressure=COALESCE(excluded.barometric_pressure,telemetry.barometric_pressure)
barometric_pressure=COALESCE(excluded.barometric_pressure,telemetry.barometric_pressure),
gas_resistance=COALESCE(excluded.gas_resistance,telemetry.gas_resistance),
current=COALESCE(excluded.current,telemetry.current),
iaq=COALESCE(excluded.iaq,telemetry.iaq),
distance=COALESCE(excluded.distance,telemetry.distance),
lux=COALESCE(excluded.lux,telemetry.lux),
white_lux=COALESCE(excluded.white_lux,telemetry.white_lux),
ir_lux=COALESCE(excluded.ir_lux,telemetry.ir_lux),
uv_lux=COALESCE(excluded.uv_lux,telemetry.uv_lux),
wind_direction=COALESCE(excluded.wind_direction,telemetry.wind_direction),
wind_speed=COALESCE(excluded.wind_speed,telemetry.wind_speed),
weight=COALESCE(excluded.weight,telemetry.weight),
wind_gust=COALESCE(excluded.wind_gust,telemetry.wind_gust),
wind_lull=COALESCE(excluded.wind_lull,telemetry.wind_lull),
radiation=COALESCE(excluded.radiation,telemetry.radiation),
rainfall_1h=COALESCE(excluded.rainfall_1h,telemetry.rainfall_1h),
rainfall_24h=COALESCE(excluded.rainfall_24h,telemetry.rainfall_24h),
soil_moisture=COALESCE(excluded.soil_moisture,telemetry.soil_moisture),
soil_temperature=COALESCE(excluded.soil_temperature,telemetry.soil_temperature)
SQL
end
@@ -15,6 +15,30 @@
module PotatoMesh
module App
module Database
# Column definitions required for environment telemetry support. Each
# entry pairs the column name with the SQL type used when backfilling
# legacy databases that pre-date the extended telemetry schema.
TELEMETRY_COLUMN_DEFINITIONS = [
["gas_resistance", "REAL"],
["current", "REAL"],
["iaq", "INTEGER"],
["distance", "REAL"],
["lux", "REAL"],
["white_lux", "REAL"],
["ir_lux", "REAL"],
["uv_lux", "REAL"],
["wind_direction", "INTEGER"],
["wind_speed", "REAL"],
["weight", "REAL"],
["wind_gust", "REAL"],
["wind_lull", "REAL"],
["radiation", "REAL"],
["rainfall_1h", "REAL"],
["rainfall_24h", "REAL"],
["soil_moisture", "INTEGER"],
["soil_temperature", "REAL"],
].freeze
# Open a connection to the application database applying common pragmas.
#
# @param readonly [Boolean] whether to open the database in read-only mode.
@@ -119,6 +143,21 @@ module PotatoMesh
sql_file = File.expand_path("../../../../data/instances.sql", __dir__)
db.execute_batch(File.read(sql_file))
end
telemetry_tables =
db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='telemetry'").flatten
if telemetry_tables.empty?
telemetry_schema = File.expand_path("../../../../data/telemetry.sql", __dir__)
db.execute_batch(File.read(telemetry_schema))
end
telemetry_columns = db.execute("PRAGMA table_info(telemetry)").map { |row| row[1] }
TELEMETRY_COLUMN_DEFINITIONS.each do |name, type|
next if telemetry_columns.include?(name)
db.execute("ALTER TABLE telemetry ADD COLUMN #{name} #{type}")
telemetry_columns << name
end
rescue SQLite3::SQLException, Errno::ENOENT => e
warn_log(
"Failed to apply schema upgrade",
+61 -7
View File
@@ -15,10 +15,25 @@
module PotatoMesh
module App
module Federation
# Resolve the canonical domain for the running instance.
#
# @return [String, nil] sanitized instance domain or nil outside production.
# @raise [RuntimeError] when the domain cannot be determined in production.
def self_instance_domain
sanitized = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
return sanitized if sanitized
unless production_environment?
debug_log(
"INSTANCE_DOMAIN unavailable; skipping self instance domain",
context: "federation.instances",
app_env: string_or_nil(ENV["APP_ENV"]),
rack_env: string_or_nil(ENV["RACK_ENV"]),
source: app_constant(:INSTANCE_DOMAIN_SOURCE),
)
return nil
end
raise "INSTANCE_DOMAIN could not be determined"
end
@@ -128,10 +143,17 @@ module PotatoMesh
db = open_database(readonly: true)
db.results_as_hash = false
rows = with_busy_retry {
db.execute("SELECT domain FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''")
}
rows.flatten.compact.each do |raw_domain|
cutoff = Time.now.to_i - PotatoMesh::Config.week_seconds
rows = with_busy_retry do
db.execute(
"SELECT domain, last_update_time FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''",
)
end
rows.each do |row|
raw_domain = row[0]
last_update_time = coerce_integer(row[1])
next unless last_update_time && last_update_time >= cutoff
sanitized = sanitize_instance_domain(raw_domain)&.downcase
next unless sanitized
next if normalized_self && sanitized == normalized_self
@@ -162,8 +184,7 @@ module PotatoMesh
begin
http = build_remote_http_client(uri)
response = http.start do |connection|
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request = build_federation_http_request(Net::HTTP::Post, uri)
request.body = payload_json
connection.request(request)
end
@@ -250,6 +271,9 @@ module PotatoMesh
end
def start_federation_announcer!
# Federation broadcasts must not execute when federation support is disabled.
return nil unless federation_enabled?
existing = settings.federation_thread
return existing if existing&.alive?
@@ -277,6 +301,9 @@ module PotatoMesh
#
# @return [Thread, nil] the thread handling the initial announcement.
def start_initial_federation_announcement!
# Skip the initial broadcast entirely when federation is disabled.
return nil unless federation_enabled?
existing = settings.respond_to?(:initial_federation_thread) ? settings.initial_federation_thread : nil
return existing if existing&.alive?
@@ -343,7 +370,8 @@ module PotatoMesh
def perform_instance_http_request(uri)
http = build_remote_http_client(uri)
http.start do |connection|
response = connection.request(Net::HTTP::Get.new(uri))
request = build_federation_http_request(Net::HTTP::Get, uri)
response = connection.request(request)
case response
when Net::HTTPSuccess
response.body
@@ -355,6 +383,32 @@ module PotatoMesh
raise_instance_fetch_error(e)
end
# Build an HTTP request decorated with the headers required for federation peers.
#
# @param request_class [Class<Net::HTTPRequest>] HTTP request class such as {Net::HTTP::Get}.
# @param uri [URI::Generic] target URI describing the remote endpoint.
# @return [Net::HTTPRequest] configured HTTP request including standard headers.
def build_federation_http_request(request_class, uri)
request = request_class.new(uri)
request["User-Agent"] = federation_user_agent_header
request["Accept"] = "application/json"
request["Content-Type"] = "application/json" if request.request_body_permitted?
request
end
# Compose the User-Agent string used when communicating with federation peers.
#
# @return [String] descriptive identifier for PotatoMesh federation requests.
def federation_user_agent_header
version = app_constant(:APP_VERSION).to_s
version = "unknown" if version.empty?
sanitized_domain = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN), downcase: true)
base = "PotatoMesh/#{version}"
return base unless sanitized_domain && !sanitized_domain.empty?
"#{base} (+https://#{sanitized_domain})"
end
# Build a human readable error message for a failed instance request.
#
# @param error [StandardError] failure raised while performing the request.
+13 -2
View File
@@ -123,6 +123,7 @@ module PotatoMesh
maxDistanceKm: PotatoMesh::Config.max_distance_km,
tileFilters: PotatoMesh::Config.tile_filters,
instanceDomain: app_constant(:INSTANCE_DOMAIN),
instancesFeatureEnabled: federation_enabled? && !private_mode?,
}
end
@@ -323,7 +324,7 @@ module PotatoMesh
#
# @return [Boolean] true when PRIVATE=1.
def private_mode?
ENV["PRIVATE"] == "1"
PotatoMesh::Config.private_mode_enabled?
end
# Identify whether the Rack environment corresponds to the test suite.
@@ -333,11 +334,21 @@ module PotatoMesh
ENV["RACK_ENV"] == "test"
end
# Determine whether the application is running in a production environment.
#
# @return [Boolean] true when APP_ENV or RACK_ENV resolves to "production".
def production_environment?
app_env = string_or_nil(ENV["APP_ENV"])&.downcase
rack_env = string_or_nil(ENV["RACK_ENV"])&.downcase
app_env == "production" || rack_env == "production"
end
# Determine whether federation features should be active.
#
# @return [Boolean] true when federation configuration allows it.
def federation_enabled?
ENV.fetch("FEDERATION", "1") != "0" && !private_mode?
PotatoMesh::Config.federation_enabled?
end
# Determine whether federation announcements should run asynchronously.
+21 -12
View File
@@ -158,7 +158,8 @@ module PotatoMesh
end
# Fetch all instance rows ready to be served by the API while handling
# malformed rows gracefully.
# malformed rows gracefully. The dataset is restricted to records updated
# within the rolling window defined by PotatoMesh::Config.week_seconds.
#
# @return [Array<Hash>] list of cleaned instance payloads.
def load_instances_for_api
@@ -166,22 +167,30 @@ module PotatoMesh
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
min_last_update_time = now - PotatoMesh::Config.week_seconds
sql = <<~SQL
SELECT id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
FROM instances
WHERE domain IS NOT NULL AND TRIM(domain) != ''
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
AND last_update_time IS NOT NULL AND last_update_time >= ?
ORDER BY LOWER(domain)
SQL
rows = with_busy_retry do
db.execute(
<<~SQL
SELECT id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
FROM instances
WHERE domain IS NOT NULL AND TRIM(domain) != ''
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
ORDER BY LOWER(domain)
SQL
)
db.execute(sql, min_last_update_time)
end
rows.each_with_object([]) do |row, memo|
normalized = normalize_instance_row(row)
memo << normalized if normalized
next unless normalized
last_update_time = normalized["lastUpdateTime"]
next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time
memo << normalized
end
rescue SQLite3::Exception => e
warn_log(
+49 -4
View File
@@ -17,6 +17,33 @@ module PotatoMesh
module Queries
MAX_QUERY_LIMIT = 1000
# Remove nil or empty values from an API response hash to reduce payload size.
# Integer keys emitted by SQLite are ignored because the JSON representation
# only exposes symbolic keys. Strings containing only whitespace are treated
# as empty to mirror sanitisation elsewhere in the application.
#
# @param row [Hash] raw database row to compact.
# @return [Hash] cleaned hash without blank values.
def compact_api_row(row)
return {} unless row.is_a?(Hash)
row.each_with_object({}) do |(key, value), acc|
next if key.is_a?(Integer)
next if value.nil?
if value.is_a?(String)
trimmed = value.strip
next if trimmed.empty?
acc[key] = value
next
end
next if value.respond_to?(:empty?) && value.empty?
acc[key] = value
end
end
# Normalise a caller-provided limit to a sane, positive integer.
#
# @param limit [Object] value coerced to an integer.
@@ -179,7 +206,7 @@ module PotatoMesh
pb = r["precision_bits"]
r["precision_bits"] = pb.to_i if pb
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -301,7 +328,7 @@ module PotatoMesh
r["pdop"] = coerce_float(r["pdop"])
r["snr"] = coerce_float(r["snr"])
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -341,7 +368,7 @@ module PotatoMesh
r["rx_iso"] = Time.at(rx_time).utc.iso8601 if rx_time
r["snr"] = coerce_float(r["snr"])
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -400,8 +427,26 @@ module PotatoMesh
r["temperature"] = coerce_float(r["temperature"])
r["relative_humidity"] = coerce_float(r["relative_humidity"])
r["barometric_pressure"] = coerce_float(r["barometric_pressure"])
r["gas_resistance"] = coerce_float(r["gas_resistance"])
r["current"] = coerce_float(r["current"])
r["iaq"] = coerce_integer(r["iaq"])
r["distance"] = coerce_float(r["distance"])
r["lux"] = coerce_float(r["lux"])
r["white_lux"] = coerce_float(r["white_lux"])
r["ir_lux"] = coerce_float(r["ir_lux"])
r["uv_lux"] = coerce_float(r["uv_lux"])
r["wind_direction"] = coerce_integer(r["wind_direction"])
r["wind_speed"] = coerce_float(r["wind_speed"])
r["weight"] = coerce_float(r["weight"])
r["wind_gust"] = coerce_float(r["wind_gust"])
r["wind_lull"] = coerce_float(r["wind_lull"])
r["radiation"] = coerce_float(r["radiation"])
r["rainfall_1h"] = coerce_float(r["rainfall_1h"])
r["rainfall_24h"] = coerce_float(r["rainfall_24h"])
r["soil_moisture"] = coerce_integer(r["soil_moisture"])
r["soil_temperature"] = coerce_float(r["soil_temperature"])
end
rows
rows.map { |row| compact_api_row(row) }
ensure
db&.close
end
@@ -17,6 +17,10 @@ module PotatoMesh
module Routes
module Api
def self.registered(app)
app.before "/api/messages*" do
halt 404 if private_mode?
end
app.get "/version" do
content_type :json
last_update = latest_node_update_timestamp
@@ -67,14 +71,12 @@ module PotatoMesh
end
app.get "/api/messages" do
halt 404 if private_mode?
content_type :json
limit = [params["limit"]&.to_i || 200, 1000].min
query_messages(limit).to_json
end
app.get "/api/messages/:id" do
halt 404 if private_mode?
content_type :json
node_ref = string_or_nil(params["id"])
halt 400, { error: "missing node id" }.to_json unless node_ref
@@ -125,6 +127,9 @@ module PotatoMesh
end
app.get "/api/instances" do
# Prevent the federation catalog from being exposed when federation is disabled.
halt 404 unless federation_enabled?
content_type :json
ensure_self_instance_record!
payload = load_instances_for_api
@@ -40,7 +40,6 @@ module PotatoMesh
end
app.post "/api/messages" do
halt 404 if private_mode?
require_token!
content_type :json
begin
@@ -62,6 +62,7 @@ module PotatoMesh
contact_link_url: sanitized_contact_link_url,
version: app_constant(:APP_VERSION),
private_mode: private_mode?,
federation_enabled: federation_enabled?,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
+61 -6
View File
@@ -32,12 +32,48 @@ module PotatoMesh
DEFAULT_FREQUENCY = "915MHz"
DEFAULT_CONTACT_LINK = "#potatomesh:dod.ngo"
DEFAULT_MAX_DISTANCE_KM = 42.0
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 5
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 12
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 15
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 60
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE = 64
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL = 256
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
# Retrieve the configured API token used for authenticated requests.
#
# @return [String, nil] API token when provided, otherwise nil.
def api_token
fetch_string("API_TOKEN", nil)
end
# Retrieve an explicit instance domain override when present.
#
# @return [String, nil] hostname or host:port pair supplied via ENV.
def instance_domain
fetch_string("INSTANCE_DOMAIN", nil)
end
# Determine whether private mode should be activated.
#
# @return [Boolean] true when PRIVATE=1 in the environment.
def private_mode_enabled?
value = ENV.fetch("PRIVATE", "0")
value.to_s.strip == "1"
end
# Determine whether federation features are permitted for the instance.
#
# Federation is disabled when ``PRIVATE=1`` regardless of the
# ``FEDERATION`` environment variable to ensure a private deployment does
# not announce itself or crawl peers.
#
# @return [Boolean] true when federation should remain active.
def federation_enabled?
return false if private_mode_enabled?
value = ENV.fetch("FEDERATION", "1")
value.to_s.strip != "0"
end
# Resolve the absolute path to the web application root directory.
#
# @return [String] absolute filesystem path of the web folder.
@@ -134,7 +170,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"v0.5.2"
"v0.5.4"
end
# Default refresh interval for frontend polling routines.
@@ -179,7 +215,7 @@ module PotatoMesh
#
# @return [String] comma separated list of report IDs.
def prom_report_ids
""
fetch_string("PROM_REPORT_IDS", "")
end
# Transform Prometheus report identifiers into a cleaned array.
@@ -276,16 +312,28 @@ module PotatoMesh
# Connection timeout used when establishing federation HTTP sockets.
#
# The timeout can be customised with the REMOTE_INSTANCE_CONNECT_TIMEOUT
# environment variable to accommodate slower or distant federation peers.
#
# @return [Integer] connect timeout in seconds.
def remote_instance_http_timeout
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT
fetch_positive_integer(
"REMOTE_INSTANCE_CONNECT_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
end
# Read timeout used when streaming federation HTTP responses.
#
# The timeout can be customised with the REMOTE_INSTANCE_READ_TIMEOUT
# environment variable to accommodate slower or distant federation peers.
#
# @return [Integer] read timeout in seconds.
def remote_instance_read_timeout
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT
fetch_positive_integer(
"REMOTE_INSTANCE_READ_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
end
# Limit the number of remote instances processed from a single response.
@@ -413,6 +461,13 @@ module PotatoMesh
fetch_string("CONTACT_LINK", DEFAULT_CONTACT_LINK)
end
# Retrieve the configured connection target for the ingestor service.
#
# @return [String] serial device, TCP endpoint, or Bluetooth target.
def connection_target
fetch_string("CONNECTION", "/dev/ttyACM0")
end
# Determine the best URL to represent the configured contact link.
#
# @return [String, nil] absolute URL when derivable, otherwise nil.
+162 -1
View File
@@ -6,7 +6,168 @@
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.0"
"version": "0.5.0",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"v8-to-istanbul": "^9.3.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
}
}
}
+6
View File
@@ -5,5 +5,11 @@
"private": true,
"scripts": {
"test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js"
},
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"v8-to-istanbul": "^9.3.0"
}
}
@@ -0,0 +1,100 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatPositionHighlights, formatTelemetryHighlights } from '../chat-log-highlights.js';
test('formatTelemetryHighlights includes formatted numeric metrics', () => {
const highlights = formatTelemetryHighlights({
temperature: 21.44,
relative_humidity: 54.27,
});
assert.deepEqual(highlights, [
{ label: 'Temperature', value: '21.4°C' },
{ label: 'Humidity', value: '54.3%' },
]);
});
test('formatTelemetryHighlights prefers nested telemetry when top-level values are stale', () => {
const highlights = formatTelemetryHighlights({
channel_utilization: 0,
device_metrics: { channelUtilization: 0.561 },
});
assert.deepEqual(highlights, [
{ label: 'Channel Util', value: '0.561%' },
]);
});
test('formatPositionHighlights renders coordinate and movement data', () => {
const highlights = formatPositionHighlights({
latitude: 52.1234567,
longitude: 13.7654321,
altitude: 150.5,
accuracy: 3.2,
speed: 1.234,
heading: 181.6,
satellites: 7,
});
assert.deepEqual(highlights, [
{ label: 'Lat', value: '52.12346' },
{ label: 'Lon', value: '13.76543' },
{ label: 'Alt', value: '150.5m' },
{ label: 'Accuracy', value: '3.2m' },
{ label: 'Speed', value: '1.2 m/s' },
{ label: 'Heading', value: '182°' },
{ label: 'Sats', value: '7' },
]);
});
test('formatPositionHighlights normalises integer microdegree fields', () => {
const highlights = formatPositionHighlights({
position: {
latitude_i: 52_123_456,
longitude_i: 13_765_432,
},
});
assert.deepEqual(highlights.slice(0, 2), [
{ label: 'Lat', value: '52.12346' },
{ label: 'Lon', value: '13.76543' },
]);
});
test('formatters return empty arrays when payloads are missing', () => {
assert.deepEqual(formatTelemetryHighlights(null), []);
assert.deepEqual(formatPositionHighlights(undefined), []);
assert.deepEqual(formatPositionHighlights({}), []);
});
test('formatPositionHighlights omits zero-valued movement metrics while keeping coordinates', () => {
const highlights = formatPositionHighlights({
latitude: 0,
longitude: 0,
altitude: 0,
speed: '0',
accuracy: 0,
});
assert.deepEqual(highlights, [
{ label: 'Lat', value: '0.00000' },
{ label: 'Lon', value: '0.00000' },
]);
});
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CHAT_LOG_ENTRY_TYPES,
buildChatTabModel,
MAX_CHANNEL_INDEX,
normaliseChannelIndex,
normaliseChannelName,
resolveTimestampSeconds
} from '../chat-log-tabs.js';
const NOW = 1_000_000;
const WINDOW = 60 * 60; // one hour
function fixtureNodes() {
return [
{ id: 'recent-node', first_heard: NOW - 120 },
{ id: 'stale-node', first_heard: NOW - WINDOW - 1 },
{ id: 'iso-node', firstHeard: null, first_heard_iso: new Date((NOW - 30) * 1000).toISOString() }
];
}
function fixtureMessages() {
return [
{ id: 'recent-default', rx_time: NOW - 5, channel: 0, channel_name: ' MediumFast ' },
{ id: 'recent-alt', rx_time: NOW - 10, channel_index: '1', channel_name: ' BerlinMesh ' },
{ id: 'stale', rx_time: NOW - WINDOW - 5, channel: 2 },
{ id: 'encrypted', rx_time: NOW - 20, channel: 3, encrypted: true },
{ id: 'no-index', rx_time: NOW - 15, channel_name: 'Fallback' },
{ id: 'too-high', rx_time: NOW - 25, channel: MAX_CHANNEL_INDEX + 5, channel_name: 'Ignored' },
{ id: 'iso-ts', rxTime: null, rx_iso: new Date((NOW - 40) * 1000).toISOString(), channel: 1 }
];
}
function buildModel(overrides = {}) {
return buildChatTabModel({
nodes: fixtureNodes(),
messages: fixtureMessages(),
nowSeconds: NOW,
windowSeconds: WINDOW,
...overrides
});
}
test('buildChatTabModel returns sorted nodes and channel buckets', () => {
const model = buildModel();
assert.equal(model.logEntries.length, 2);
assert.deepEqual(model.logEntries.map(entry => entry.type), [
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.NODE_NEW
]);
assert.deepEqual(model.logEntries.map(entry => entry.node.id), ['recent-node', 'iso-node']);
assert.equal(model.channels.length, 2);
const [channel0, channel1] = model.channels;
assert.equal(channel0.index, 0);
assert.equal(channel0.label, 'MediumFast');
assert.equal(channel0.entries.length, 2);
assert.deepEqual(channel0.entries.map(entry => entry.message.id), ['no-index', 'recent-default']);
assert.equal(channel1.index, 1);
assert.equal(channel1.label, 'BerlinMesh');
assert.equal(channel1.entries.length, 2);
assert.deepEqual(channel1.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
});
test('buildChatTabModel always includes channel zero bucket', () => {
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[0].entries.length, 0);
});
test('normaliseChannelIndex handles numeric and textual input', () => {
assert.equal(normaliseChannelIndex(2.9), 2);
assert.equal(normaliseChannelIndex(' 7 '), 7);
assert.equal(normaliseChannelIndex('bad'), null);
assert.equal(normaliseChannelIndex(null), null);
});
test('normaliseChannelName trims strings and allows numeric values', () => {
assert.equal(normaliseChannelName(' Berlin '), 'Berlin');
assert.equal(normaliseChannelName(5), '5');
assert.equal(normaliseChannelName(''), null);
assert.equal(normaliseChannelName(undefined), null);
});
test('resolveTimestampSeconds prefers numeric but falls back to ISO parsing', () => {
assert.equal(resolveTimestampSeconds(1234, null), 1234);
const iso = '1970-01-01T00:10:00Z';
assert.equal(resolveTimestampSeconds('not-numeric', iso), 600);
assert.equal(resolveTimestampSeconds('bad', 'invalid'), null);
});
test('buildChatTabModel includes telemetry, position, and neighbor events', () => {
const nodeId = '!node';
const neighborId = '!peer';
const model = buildChatTabModel({
nodes: [{
node_id: nodeId,
first_heard: NOW - 50,
last_heard: NOW - 40,
short_name: 'NODE',
long_name: 'Node Example'
}],
telemetry: [{ node_id: nodeId, rx_time: NOW - 30 }],
positions: [{ node_id: nodeId, rx_time: NOW - 20 }],
neighbors: [{ node_id: nodeId, neighbor_id: neighborId, rx_time: NOW - 10 }],
messages: [],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.deepEqual(model.logEntries.map(entry => entry.type), [
CHAT_LOG_ENTRY_TYPES.NODE_NEW,
CHAT_LOG_ENTRY_TYPES.NODE_INFO,
CHAT_LOG_ENTRY_TYPES.TELEMETRY,
CHAT_LOG_ENTRY_TYPES.POSITION,
CHAT_LOG_ENTRY_TYPES.NEIGHBOR
]);
assert.equal(model.logEntries[0].nodeId, nodeId);
const lastEntry = model.logEntries[model.logEntries.length - 1];
assert.equal(lastEntry.neighborId, neighborId);
});
@@ -0,0 +1,194 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderChatTabs } from '../chat-tabs.js';
class MockClassList {
constructor() {
this._values = new Set();
}
add(...names) {
names.forEach(name => {
if (name) this._values.add(name);
});
}
remove(...names) {
names.forEach(name => {
if (name) this._values.delete(name);
});
}
contains(name) {
return this._values.has(name);
}
}
class MockFragment {
constructor() {
this.children = [];
this.isFragment = true;
}
appendChild(node) {
this.children.push(node);
return node;
}
}
class MockElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.attributes = new Map();
this.dataset = {};
this.classList = new MockClassList();
this.listeners = new Map();
this.hidden = false;
this.scrollTop = 0;
this.scrollHeight = 200;
}
appendChild(node) {
this.children.push(node);
return node;
}
replaceChildren(...nodes) {
this.children = [];
for (const node of nodes) {
if (!node) continue;
if (node.isFragment && Array.isArray(node.children)) {
this.children.push(...node.children);
} else {
this.children.push(node);
}
}
}
setAttribute(name, value) {
const strValue = String(value);
this.attributes.set(name, strValue);
if (name === 'id') {
this.id = strValue;
}
if (name.startsWith('data-')) {
const key = name
.slice(5)
.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
this.dataset[key] = strValue;
}
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
addEventListener(event, handler) {
this.listeners.set(event, handler);
}
dispatch(event) {
const handler = this.listeners.get(event);
if (handler) {
handler({});
}
}
}
function createMockDocument() {
return {
createElement(tag) {
return new MockElement(tag);
},
createDocumentFragment() {
return new MockFragment();
}
};
}
test('renderChatTabs creates tab markup and selects default active tab', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'channel-0', label: 'Default', content: new MockElement('div') },
{ id: 'channel-1', label: 'Alt', content: new MockElement('div') }
];
const active = renderChatTabs({
document,
container,
tabs,
defaultActiveTabId: 'channel-0'
});
assert.equal(active, 'channel-0');
assert.equal(container.dataset.activeTab, 'channel-0');
assert.equal(container.children.length, 2);
const [tabList, panelWrapper] = container.children;
assert.equal(tabList.children.length, 3);
assert.equal(panelWrapper.children.length, 3);
assert.equal(panelWrapper.children[1].hidden, false);
assert.equal(panelWrapper.children[1].scrollTop, panelWrapper.children[1].scrollHeight);
assert.equal(panelWrapper.children[0].hidden, true);
tabList.children[0].dispatch('click');
assert.equal(container.dataset.activeTab, 'log');
assert.equal(panelWrapper.children[0].hidden, false);
assert.equal(panelWrapper.children[1].hidden, true);
});
test('renderChatTabs reuses previous active tab when still available', () => {
const document = createMockDocument();
const container = new MockElement('div');
container.dataset.activeTab = 'log';
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'channel-0', label: 'Default', content: new MockElement('div') }
];
const active = renderChatTabs({
document,
container,
tabs,
previousActiveTabId: 'log',
defaultActiveTabId: 'channel-0'
});
assert.equal(active, 'log');
const [tabList, panels] = container.children;
assert.equal(tabList.children[0].getAttribute('aria-selected'), 'true');
assert.equal(panels.children[0].hidden, false);
});
test('renderChatTabs clears container when no tabs exist', () => {
const document = createMockDocument();
const container = new MockElement('div');
container.replaceChildren(new MockElement('span'));
const active = renderChatTabs({ document, container, tabs: [] });
assert.equal(active, null);
assert.equal(container.children.length, 0);
assert.equal(container.dataset.activeTab, '');
});
@@ -0,0 +1,172 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { buildInstanceUrl, initializeInstanceSelector, __test__ } from '../instance-selector.js';
const { resolveInstanceLabel } = __test__;
function setupSelectElement(document) {
const select = document.createElement('select');
const listeners = new Map();
const options = [];
Object.defineProperty(select, 'options', {
get() {
return options;
}
});
Object.defineProperty(select, 'value', {
get() {
if (typeof select.selectedIndex !== 'number') {
return '';
}
const current = options[select.selectedIndex];
return current ? current.value : '';
},
set(newValue) {
const index = options.findIndex(option => option.value === newValue);
select.selectedIndex = index >= 0 ? index : -1;
}
});
select.selectedIndex = -1;
select.appendChild = option => {
options.push(option);
if (select.selectedIndex === -1) {
select.selectedIndex = 0;
}
return option;
};
select.remove = index => {
if (index >= 0 && index < options.length) {
options.splice(index, 1);
if (options.length === 0) {
select.selectedIndex = -1;
} else if (select.selectedIndex >= options.length) {
select.selectedIndex = options.length - 1;
}
}
};
select.addEventListener = (event, handler) => {
listeners.set(event, handler);
};
select.dispatchEvent = event => {
const key = typeof event === 'string' ? event : event?.type;
const handler = listeners.get(key);
if (handler) {
handler(event);
}
};
return select;
}
test('resolveInstanceLabel falls back to the domain when the name is missing', () => {
assert.equal(resolveInstanceLabel({ domain: 'mesh.example' }), 'mesh.example');
assert.equal(resolveInstanceLabel({ name: ' Mesh Name ' }), 'Mesh Name');
assert.equal(resolveInstanceLabel(null), '');
});
test('buildInstanceUrl normalises domains into navigable HTTPS URLs', () => {
assert.equal(buildInstanceUrl('mesh.example'), 'https://mesh.example');
assert.equal(buildInstanceUrl(' https://mesh.example '), 'https://mesh.example');
assert.equal(buildInstanceUrl(''), null);
assert.equal(buildInstanceUrl(null), null);
});
test('initializeInstanceSelector populates options alphabetically and selects the configured domain', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const fetchCalls = [];
const fetchImpl = async url => {
fetchCalls.push(url);
return {
ok: true,
async json() {
return [
{ name: 'Zulu Mesh', domain: 'zulu.mesh' },
{ name: 'Alpha Mesh', domain: 'alpha.mesh' },
{ domain: 'beta.mesh' }
];
}
};
};
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document,
instanceDomain: 'beta.mesh',
defaultLabel: 'Select region ...'
});
assert.equal(fetchCalls.length, 1);
assert.equal(select.options.length, 4);
assert.equal(select.options[0].textContent, 'Select region ...');
assert.equal(select.options[1].textContent, 'Alpha Mesh');
assert.equal(select.options[2].textContent, 'beta.mesh');
assert.equal(select.options[3].textContent, 'Zulu Mesh');
assert.equal(select.options[select.selectedIndex].value, 'beta.mesh');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector navigates to the chosen instance domain', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const fetchImpl = async () => ({
ok: true,
async json() {
return [{ domain: 'mesh.example' }];
}
});
let navigatedTo = null;
const navigate = url => {
navigatedTo = url;
};
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document,
navigate,
defaultLabel: 'Select region ...'
});
assert.equal(select.options.length, 2);
assert.equal(select.options[1].value, 'mesh.example');
select.value = 'mesh.example';
select.dispatchEvent({ type: 'change', target: select });
assert.equal(navigatedTo, 'https://mesh.example');
} finally {
env.cleanup();
}
});
@@ -0,0 +1,193 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
collectTelemetryMetrics,
} from '../short-info-telemetry.js';
test('collectTelemetryMetrics extracts values from nested payloads', () => {
const payload = {
battery: '100',
device_metrics: {
voltage: 4.224,
airUtilTx: 0.051,
uptimeSeconds: 305044,
},
environment_metrics: {
temperature: 21.98,
relativeHumidity: 39.5,
barometricPressure: 1017.8,
gasResistance: 1456,
iaq: 83,
distance: 12.5,
lux: 100.25,
whiteLux: 64.5,
irLux: 12.75,
uvLux: 1.6,
windDirection: 270,
windSpeed: 5.9,
windGust: 7.4,
windLull: 4.8,
weight: 32.7,
radiation: 0.45,
rainfall1h: 0.18,
rainfall24h: 1.42,
soilMoisture: 3100,
soilTemperature: 18.9,
},
};
const metrics = collectTelemetryMetrics(payload);
assert.equal(metrics.battery, 100);
assert.equal(metrics.voltage, 4.224);
assert.equal(metrics.airUtil, 0.051);
assert.equal(metrics.uptime, 305044);
assert.equal(metrics.temperature, 21.98);
assert.equal(metrics.humidity, 39.5);
assert.equal(metrics.pressure, 1017.8);
assert.equal(metrics.gasResistance, 1456);
assert.equal(metrics.iaq, 83);
assert.equal(metrics.distance, 12.5);
assert.equal(metrics.lux, 100.25);
assert.equal(metrics.whiteLux, 64.5);
assert.equal(metrics.irLux, 12.75);
assert.equal(metrics.uvLux, 1.6);
assert.equal(metrics.windDirection, 270);
assert.equal(metrics.windSpeed, 5.9);
assert.equal(metrics.windGust, 7.4);
assert.equal(metrics.windLull, 4.8);
assert.equal(metrics.weight, 32.7);
assert.equal(metrics.radiation, 0.45);
assert.equal(metrics.rainfall1h, 0.18);
assert.equal(metrics.rainfall24h, 1.42);
assert.equal(metrics.soilMoisture, 3100);
assert.equal(metrics.soilTemperature, 18.9);
});
test('collectTelemetryMetrics prefers latest nested telemetry values over stale top-level metrics', () => {
const payload = {
channel_utilization: 0,
device_metrics: {
channel_utilization: 0.561,
air_util_tx: 0.0091,
},
telemetry: {
channel: 0.563,
},
raw: {
device_metrics: {
channelUtilization: 0.562,
},
},
};
const metrics = collectTelemetryMetrics(payload);
assert.equal(metrics.channel, 0.563);
assert.equal(metrics.airUtil, 0.0091);
});
test('collectTelemetryMetrics prefers utilisation metrics over channel indices', () => {
const metrics = collectTelemetryMetrics({
channel: 0,
channel_utilization: 0.013,
});
assert.equal(metrics.channel, 0.013);
});
test('collectTelemetryMetrics ignores non-numeric values', () => {
const metrics = collectTelemetryMetrics({
battery: '',
voltage: 'abc',
rainfall_1h: null,
wind_speed: undefined,
});
for (const field of TELEMETRY_FIELDS) {
assert.ok(!(field.key in metrics));
}
});
test('buildTelemetryDisplayEntries formats values for overlays', () => {
const telemetry = {
battery: 99,
voltage: 4.224,
current: 0.0715,
uptime: 305044,
channel: 0.5967,
airUtil: 0.03908,
temperature: 21.98,
humidity: 39.5,
pressure: 1017.8,
gasResistance: 1456,
iaq: 83,
distance: 12.5,
lux: 100.25,
whiteLux: 64.5,
irLux: 12.75,
uvLux: 1.6,
windDirection: 270,
windSpeed: 5.9,
windGust: 7.4,
windLull: 4.8,
weight: 32.7,
radiation: 0.45,
rainfall1h: 0.18,
rainfall24h: 1.42,
soilMoisture: 3100,
soilTemperature: 18.9,
};
const entries = buildTelemetryDisplayEntries(telemetry, {
formatUptime: value => `formatted-${value}`,
});
const entryMap = new Map(entries.map(entry => [entry.label, entry.value]));
assert.equal(entryMap.get('Battery'), '99%');
assert.equal(entryMap.get('Voltage'), '4.224V');
assert.equal(entryMap.get('Current'), '71.5 mA');
assert.equal(entryMap.get('Uptime'), 'formatted-305044');
assert.equal(entryMap.get('Channel Util'), '0.597%');
assert.equal(entryMap.get('Air Util Tx'), '0.039%');
assert.equal(entryMap.get('Temperature'), '22.0°C');
assert.equal(entryMap.get('Humidity'), '39.5%');
assert.equal(entryMap.get('Pressure'), '1017.8 hPa');
assert.equal(entryMap.get('Gas Resistance'), '1.46 kΩ');
assert.equal(entryMap.get('IAQ'), '83');
assert.equal(entryMap.get('Distance'), '12.50 m');
assert.equal(entryMap.get('Lux'), '100.3 lx');
assert.equal(entryMap.get('White Lux'), '64.5 lx');
assert.equal(entryMap.get('IR Lux'), '12.8 lx');
assert.equal(entryMap.get('UV Lux'), '1.6 lx');
assert.equal(entryMap.get('Wind Direction'), '270°');
assert.equal(entryMap.get('Wind Speed'), '5.9 m/s');
assert.equal(entryMap.get('Wind Gust'), '7.4 m/s');
assert.equal(entryMap.get('Wind Lull'), '4.8 m/s');
assert.equal(entryMap.get('Weight'), '32.70 kg');
assert.equal(entryMap.get('Radiation'), '0.45 µSv/h');
assert.equal(entryMap.get('Rainfall 1h'), '0.18 mm');
assert.equal(entryMap.get('Rainfall 24h'), '1.42 mm');
assert.equal(entryMap.get('Soil Moisture'), '3100');
assert.equal(entryMap.get('Soil Temperature'), '18.9°C');
});
test('buildTelemetryDisplayEntries omits empty metrics', () => {
const entries = buildTelemetryDisplayEntries({ uptime: null }, {
formatUptime: () => '',
});
assert.equal(entries.length, 0);
});
@@ -0,0 +1,258 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { buildTelemetryDisplayEntries, collectTelemetryMetrics, fmtAlt } from './short-info-telemetry.js';
/**
* Field descriptors inspected when highlighting position updates.
*
* Each entry describes a label, possible property names to inspect within a
* position payload, and a formatter that converts the raw value into a string.
*
* @type {Array<{
* label: string,
* sources: Array<string>,
* formatter: (value: *) => string,
* suppressZero?: boolean
* }>}
*/
const POSITION_HIGHLIGHT_FIELDS = Object.freeze([
{ label: 'Lat', sources: ['latitude', 'lat', 'latitude_i'], formatter: value => formatCoordinate(value, 5) },
{ label: 'Lon', sources: ['longitude', 'lon', 'longitude_i'], formatter: value => formatCoordinate(value, 5) },
{ label: 'Alt', sources: ['altitude', 'alt'], formatter: value => fmtAlt(value, 'm'), suppressZero: true },
{
label: 'Accuracy',
sources: ['accuracy', 'pos_accuracy', 'position_accuracy', 'horizontal_accuracy', 'horz_accuracy'],
formatter: value => fmtAlt(value, 'm'),
suppressZero: true,
},
{ label: 'Speed', sources: ['speed', 'ground_speed', 'groundSpeed'], formatter: formatSpeed, suppressZero: true },
{ label: 'Heading', sources: ['heading', 'course', 'bearing'], formatter: formatHeading },
{ label: 'Sats', sources: ['satellites', 'sats', 'num_sats', 'numSats'], formatter: formatInteger },
]);
/**
* Convert arbitrary values to finite numbers when possible.
*
* @param {*} value Raw value.
* @returns {?number} Normalised finite number or ``null`` for invalid input.
*/
function toFiniteNumber(value) {
if (value == null || value === '') {
return null;
}
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : null;
}
/**
* Extract the first present value from ``source`` using ``keys``.
*
* @param {?Object} source Candidate container.
* @param {Array<string>} keys Ordered list of property names.
* @returns {*} First non-nullish value or ``null`` when absent.
*/
function pickFirstValueWithKey(source, keys) {
if (!source || typeof source !== 'object' || !Array.isArray(keys)) {
return null;
}
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) {
continue;
}
const value = source[key];
if (value == null || value === '') {
continue;
}
return { value, key };
}
return null;
}
/**
* Retrieve position values from the provided payload.
*
* @param {*} positionPayload Raw position record.
* @param {Array<string>} keys Candidate property names.
* @returns {{value: *, key: string}|null} First value discovered in any supported container.
*/
function extractPositionValue(positionPayload, keys) {
if (!positionPayload || typeof positionPayload !== 'object') {
return null;
}
const containers = [positionPayload];
const nestedCandidates = ['position', 'gps'];
for (const property of nestedCandidates) {
if (positionPayload[property] && typeof positionPayload[property] === 'object') {
containers.push(positionPayload[property]);
}
}
for (const container of containers) {
const result = pickFirstValueWithKey(container, keys);
if (result) {
return result;
}
}
return null;
}
/**
* Normalise raw position values based on the originating property.
*
* @param {*} value Raw value extracted from the payload.
* @param {string} [sourceKey] Property name the value originated from.
* @returns {*} Normalised value ready for formatting.
*/
function normalizePositionValue(value, sourceKey) {
if (value == null || value === '') {
return value;
}
if (typeof sourceKey === 'string' && sourceKey.endsWith('_i')) {
const numeric = toFiniteNumber(value);
return numeric == null ? value : numeric / 1_000_000;
}
if (typeof sourceKey === 'string' && ['latitude', 'longitude', 'lat', 'lon'].includes(sourceKey)) {
const numeric = toFiniteNumber(value);
if (numeric != null && Math.abs(numeric) > 180 && Math.abs(numeric) <= 180_000_000) {
return numeric / 1_000_000;
}
}
return value;
}
/**
* Format coordinate values with a configurable precision.
*
* @param {*} value Raw coordinate value.
* @param {number} [decimals=5] Decimal precision applied when formatting.
* @returns {string} Formatted coordinate string or an empty string on failure.
*/
function formatCoordinate(value, decimals = 5) {
if (value == null || value === '') {
return '';
}
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return numeric.toFixed(decimals);
}
/**
* Format velocity readings in metres per second.
*
* @param {*} value Raw speed value.
* @returns {string} Formatted speed string or an empty string when absent.
*/
function formatSpeed(value) {
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return `${numeric.toFixed(1)} m/s`;
}
/**
* Format directional readings in degrees.
*
* @param {*} value Raw heading value.
* @returns {string} Heading string or an empty string when invalid.
*/
function formatHeading(value) {
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return `${Math.round(numeric)}°`;
}
/**
* Format integer-like values such as satellite counts.
*
* @param {*} value Raw numeric input.
* @returns {string} Integer string or an empty string on failure.
*/
function formatInteger(value) {
const numeric = toFiniteNumber(value);
if (numeric == null) {
return '';
}
return String(Math.round(numeric));
}
/**
* Build highlight entries for telemetry broadcasts.
*
* The returned collection contains label/value tuples describing only the
* fields present within ``telemetryPayload``.
*
* @param {*} telemetryPayload Raw telemetry record.
* @returns {Array<{label: string, value: string}>} Highlight entries.
*/
export function formatTelemetryHighlights(telemetryPayload) {
if (!telemetryPayload || typeof telemetryPayload !== 'object') {
return [];
}
const metrics = collectTelemetryMetrics(telemetryPayload);
if (!metrics || Object.keys(metrics).length === 0) {
return [];
}
const entries = buildTelemetryDisplayEntries(metrics);
return entries.map(entry => ({ label: entry.label, value: entry.value }));
}
/**
* Build highlight entries for position broadcasts.
*
* Only non-empty values discovered in the payload are returned.
*
* @param {*} positionPayload Raw position record.
* @returns {Array<{label: string, value: string}>} Highlight entries.
*/
export function formatPositionHighlights(positionPayload) {
if (!positionPayload || typeof positionPayload !== 'object') {
return [];
}
const highlights = [];
for (const field of POSITION_HIGHLIGHT_FIELDS) {
const extracted = extractPositionValue(positionPayload, field.sources);
if (!extracted || extracted.value == null || extracted.value === '') {
continue;
}
const rawValue = normalizePositionValue(extracted.value, extracted.key);
if (field.suppressZero) {
const numeric = toFiniteNumber(rawValue);
if (numeric === 0) {
continue;
}
}
let formatted = field.formatter(rawValue);
if (formatted == null) {
continue;
}
formatted = String(formatted).trim();
if (formatted.length === 0) {
continue;
}
highlights.push({ label: field.label, value: formatted });
}
return highlights;
}
export default {
formatTelemetryHighlights,
formatPositionHighlights,
};
+291
View File
@@ -0,0 +1,291 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Highest channel index that should be represented within the tab view.
* @type {number}
*/
export const MAX_CHANNEL_INDEX = 9;
/**
* Discrete event types that can appear in the chat activity log.
*
* @type {{
* NODE_NEW: 'node-new',
* NODE_INFO: 'node-info',
* TELEMETRY: 'telemetry',
* POSITION: 'position',
* NEIGHBOR: 'neighbor'
* }}
*/
export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
NODE_NEW: 'node-new',
NODE_INFO: 'node-info',
TELEMETRY: 'telemetry',
POSITION: 'position',
NEIGHBOR: 'neighbor'
});
/**
* Build a data model describing the content for chat tabs.
*
* Entries outside the recent activity window, encrypted messages, and
* channels above {@link MAX_CHANNEL_INDEX} are filtered out.
*
* @param {{
* nodes?: Array<Object>,
* telemetry?: Array<Object>,
* positions?: Array<Object>,
* neighbors?: Array<Object>,
* messages?: Array<Object>,
* nowSeconds: number,
* windowSeconds: number,
* maxChannelIndex?: number
* }} params Aggregation inputs.
* @returns {{
* logEntries: Array<{ ts: number, type: string, nodeId?: string, nodeNum?: number }>,
* channels: Array<{ index: number, label: string, entries: Array<{ ts: number, message: Object }> }>
* }} Sorted tab model data.
*/
export function buildChatTabModel({
nodes = [],
telemetry = [],
positions = [],
neighbors = [],
messages = [],
nowSeconds,
windowSeconds,
maxChannelIndex = MAX_CHANNEL_INDEX
}) {
const cutoff = (Number.isFinite(nowSeconds) ? nowSeconds : 0) - (Number.isFinite(windowSeconds) ? windowSeconds : 0);
const logEntries = [];
const channelBuckets = new Map();
for (const node of nodes || []) {
if (!node) continue;
const nodeId = normaliseNodeId(node);
const nodeNum = normaliseNodeNum(node);
const firstTs = resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
if (firstTs != null && firstTs >= cutoff) {
logEntries.push({ ts: firstTs, type: CHAT_LOG_ENTRY_TYPES.NODE_NEW, node, nodeId, nodeNum });
}
const lastTs = resolveTimestampSeconds(node.last_heard ?? node.lastHeard, node.last_seen_iso ?? node.lastSeenIso);
if (lastTs != null && lastTs >= cutoff) {
logEntries.push({ ts: lastTs, type: CHAT_LOG_ENTRY_TYPES.NODE_INFO, node, nodeId, nodeNum });
}
}
for (const telemetryEntry of telemetry || []) {
if (!telemetryEntry) continue;
const ts = resolveTimestampSeconds(
telemetryEntry.rx_time ?? telemetryEntry.rxTime ?? telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime,
telemetryEntry.rx_iso ?? telemetryEntry.rxIso ?? telemetryEntry.telemetry_time_iso ?? telemetryEntry.telemetryTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(telemetryEntry);
const nodeNum = normaliseNodeNum(telemetryEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.TELEMETRY, telemetry: telemetryEntry, nodeId, nodeNum });
}
for (const positionEntry of positions || []) {
if (!positionEntry) continue;
const ts = resolveTimestampSeconds(
positionEntry.rx_time ?? positionEntry.rxTime ?? positionEntry.position_time ?? positionEntry.positionTime,
positionEntry.rx_iso ?? positionEntry.rxIso ?? positionEntry.position_time_iso ?? positionEntry.positionTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(positionEntry);
const nodeNum = normaliseNodeNum(positionEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.POSITION, position: positionEntry, nodeId, nodeNum });
}
for (const neighborEntry of neighbors || []) {
if (!neighborEntry) continue;
const ts = resolveTimestampSeconds(neighborEntry.rx_time ?? neighborEntry.rxTime, neighborEntry.rx_iso ?? neighborEntry.rxIso);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(neighborEntry);
const nodeNum = normaliseNodeNum(neighborEntry);
const neighborId = normaliseNeighborId(neighborEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, neighbor: neighborEntry, nodeId, nodeNum, neighborId });
}
logEntries.sort((a, b) => a.ts - b.ts);
for (const message of messages || []) {
if (!message || message.encrypted) continue;
const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso);
if (ts == null || ts < cutoff) continue;
const rawIndex = message.channel ?? message.channel_index ?? message.channelIndex;
const channelIndex = normaliseChannelIndex(rawIndex);
if (channelIndex != null && channelIndex > maxChannelIndex) {
continue;
}
const safeIndex = channelIndex != null && channelIndex >= 0 ? channelIndex : 0;
const bucketKey = safeIndex;
let bucket = channelBuckets.get(bucketKey);
if (!bucket) {
bucket = {
index: safeIndex,
label: String(safeIndex),
entries: [],
hasExplicitName: false
};
channelBuckets.set(bucketKey, bucket);
}
const channelName = normaliseChannelName(
message.channel_name ?? message.channelName ?? message.channel_display ?? message.channelDisplay
);
if (channelName && !bucket.hasExplicitName) {
bucket.label = channelName;
bucket.hasExplicitName = true;
}
bucket.entries.push({ ts, message });
}
if (!channelBuckets.has(0)) {
channelBuckets.set(0, {
index: 0,
label: '0',
entries: [],
hasExplicitName: false
});
}
const channels = Array.from(channelBuckets.values()).sort((a, b) => a.index - b.index);
for (const channel of channels) {
channel.entries.sort((a, b) => a.ts - b.ts);
}
return { logEntries, channels };
}
/**
* Extract a canonical node identifier from a payload when available.
*
* @param {*} value Arbitrary payload candidate.
* @returns {?string} Canonical node identifier.
*/
function normaliseNodeId(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_id ?? value.nodeId ?? null;
return typeof raw === 'string' && raw.trim().length ? raw.trim() : null;
}
/**
* Extract a canonical neighbour identifier from a payload when available.
*
* @param {*} value Arbitrary payload candidate.
* @returns {?string} Canonical neighbour identifier.
*/
function normaliseNeighborId(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.neighbor_id ?? value.neighborId ?? null;
if (typeof raw === 'string' && raw.trim().length) {
return raw.trim();
}
return null;
}
/**
* Extract a finite node number from a payload when available.
*
* @param {*} value Arbitrary payload candidate.
* @returns {?number} Canonical numeric identifier.
*/
function normaliseNodeNum(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_num ?? value.nodeNum ?? value.num;
if (raw == null || raw === '') return null;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Convert candidate values to timestamp seconds when possible.
*
* @param {*} numeric Numeric timestamp representation.
* @param {*} isoString ISO timestamp fallback.
* @returns {?number} Timestamp in seconds when parsing succeeds.
*/
export function resolveTimestampSeconds(numeric, isoString) {
if (numeric !== null && numeric !== undefined && numeric !== '') {
const numericValue = typeof numeric === 'number' ? numeric : Number(numeric);
if (Number.isFinite(numericValue)) {
return numericValue;
}
}
if (typeof isoString === 'string' && isoString.length) {
const parsed = Date.parse(isoString);
if (Number.isFinite(parsed)) {
return parsed / 1000;
}
}
return null;
}
/**
* Sanitise channel identifiers into bounded integers.
*
* @param {*} value Raw channel index candidate.
* @returns {?number} Non-negative integer when available.
*/
export function normaliseChannelIndex(value) {
if (value == null || value === '') {
return null;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null;
return Math.trunc(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
return Math.trunc(parsed);
}
return null;
}
/**
* Normalise channel names to trimmed display strings.
*
* @param {*} value Raw channel name candidate.
* @returns {?string} Cleaned channel label when present.
*/
export function normaliseChannelName(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return null;
}
export const __test__ = {
resolveTimestampSeconds,
normaliseChannelIndex,
normaliseChannelName
};
+187
View File
@@ -0,0 +1,187 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Render an accessible tab interface within ``container``.
*
* @param {{
* document: Document,
* container: HTMLElement,
* tabs: Array<{ id: string, label: string, content: Node|null }>,
* previousActiveTabId?: string|null,
* defaultActiveTabId?: string|null
* }} options Rendering parameters.
* @returns {?string} Identifier of the active tab after rendering.
*/
export function renderChatTabs({
document,
container,
tabs,
previousActiveTabId = null,
defaultActiveTabId = null
}) {
if (!container || !document) {
return null;
}
const validTabs = Array.isArray(tabs) ? tabs.filter(Boolean) : [];
if (validTabs.length === 0) {
if (typeof container.replaceChildren === 'function') {
container.replaceChildren();
} else {
container.innerHTML = '';
}
container.dataset.activeTab = '';
return null;
}
const fragment = createFragment(document);
const tabList = document.createElement('div');
tabList.className = 'chat-tablist';
tabList.setAttribute('role', 'tablist');
const panelWrapper = document.createElement('div');
panelWrapper.className = 'chat-tabpanels';
fragment.appendChild(tabList);
fragment.appendChild(panelWrapper);
const tabElements = [];
const existingActive = container.dataset?.activeTab || null;
const activeCandidateOrder = [existingActive, previousActiveTabId, defaultActiveTabId];
let activeTabId = null;
const idSet = new Set();
for (const tab of validTabs) {
if (!tab || typeof tab.id !== 'string' || tab.id.length === 0) {
continue;
}
const uniqueId = tab.id;
if (idSet.has(uniqueId)) {
continue;
}
idSet.add(uniqueId);
const button = document.createElement('button');
button.type = 'button';
button.className = 'chat-tab';
button.classList.add('chat-tab');
button.setAttribute('role', 'tab');
button.setAttribute('id', `chat-tab-${uniqueId}`);
button.dataset.tabId = uniqueId;
button.textContent = tab.label || '';
button.setAttribute('aria-selected', 'false');
button.setAttribute('tabindex', '-1');
const panel = document.createElement('div');
panel.className = 'chat-tabpanel';
panel.classList.add('chat-tabpanel');
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('id', `chat-panel-${uniqueId}`);
panel.setAttribute('aria-labelledby', button.getAttribute('id'));
panel.hidden = true;
if (tab.content) {
panel.appendChild(tab.content);
}
tabList.appendChild(button);
panelWrapper.appendChild(panel);
tabElements.push({ id: uniqueId, button, panel });
}
if (tabElements.length === 0) {
if (typeof container.replaceChildren === 'function') {
container.replaceChildren();
} else {
container.innerHTML = '';
}
container.dataset.activeTab = '';
return null;
}
for (const candidate of activeCandidateOrder) {
if (candidate && tabElements.some(entry => entry.id === candidate)) {
activeTabId = candidate;
break;
}
}
if (!activeTabId) {
activeTabId = tabElements[0].id;
}
if (typeof container.replaceChildren === 'function') {
container.replaceChildren(fragment);
} else {
container.innerHTML = '';
container.appendChild(fragment);
}
const setActiveTab = newId => {
if (!newId) return;
let matched = false;
for (const entry of tabElements) {
const isActive = entry.id === newId;
entry.button.setAttribute('aria-selected', isActive ? 'true' : 'false');
entry.button.setAttribute('tabindex', isActive ? '0' : '-1');
if (isActive) {
entry.button.classList.add('is-active');
entry.panel.hidden = false;
matched = true;
container.dataset.activeTab = newId;
if (typeof entry.panel.scrollHeight === 'number' && typeof entry.panel.scrollTop === 'number') {
entry.panel.scrollTop = entry.panel.scrollHeight;
}
} else {
entry.button.classList.remove('is-active');
entry.panel.hidden = true;
}
}
if (!matched) {
container.dataset.activeTab = '';
}
};
setActiveTab(activeTabId);
for (const entry of tabElements) {
entry.button.addEventListener('click', () => {
setActiveTab(entry.id);
});
}
return container.dataset.activeTab || null;
}
/**
* Create a DOM fragment with a graceful fallback for test environments.
*
* @param {Document} document Active document instance.
* @returns {{ appendChild: Function }} Fragment-like node.
*/
function createFragment(document) {
if (document && typeof document.createDocumentFragment === 'function') {
return document.createDocumentFragment();
}
const nodes = [];
return {
childNodes: nodes,
appendChild(node) {
nodes.push(node);
return node;
}
};
}
export const __test__ = { createFragment };
@@ -0,0 +1,217 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Determine the most suitable label for an instance list entry.
*
* @param {{ name?: string, domain?: string }} entry Instance record as returned by the API.
* @returns {string} Preferred display label falling back to the domain.
*/
function resolveInstanceLabel(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (name.length > 0) {
return name;
}
const domain = typeof entry.domain === 'string' ? entry.domain.trim() : '';
return domain;
}
/**
* Construct a navigable URL for the provided instance domain.
*
* @param {string} domain Instance domain as returned by the federation catalog.
* @returns {string|null} Navigable absolute URL or ``null`` when the domain is empty.
*/
export function buildInstanceUrl(domain) {
if (typeof domain !== 'string') {
return null;
}
const trimmed = domain.trim();
if (!trimmed) {
return null;
}
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(trimmed)) {
return trimmed;
}
return `https://${trimmed}`;
}
/**
* Populate and activate the federation instance selector control.
*
* @param {{
* selectElement: HTMLSelectElement | null,
* fetchImpl?: typeof fetch,
* windowObject?: Window,
* documentObject?: Document,
* instanceDomain?: string,
* defaultLabel?: string,
* navigate?: (url: string) => void,
* }} options Configuration for the selector behaviour.
* @returns {Promise<void>} Promise resolving once the selector has been initialised.
*/
export async function initializeInstanceSelector(options) {
const {
selectElement,
fetchImpl = typeof fetch === 'function' ? fetch : null,
windowObject = typeof window !== 'undefined' ? window : undefined,
documentObject = typeof document !== 'undefined' ? document : undefined,
instanceDomain,
defaultLabel = 'Select region ...',
navigate,
} = options;
if (!selectElement || typeof selectElement !== 'object') {
return;
}
const doc = documentObject || windowObject?.document || null;
if (selectElement.options.length === 0) {
const optionFactory =
(doc && typeof doc.createElement === 'function')
? doc.createElement.bind(doc)
: (typeof selectElement.ownerDocument?.createElement === 'function'
? selectElement.ownerDocument.createElement.bind(selectElement.ownerDocument)
: null);
if (optionFactory) {
const placeholderOption = optionFactory('option');
placeholderOption.value = '';
placeholderOption.textContent = defaultLabel;
selectElement.appendChild(placeholderOption);
}
} else if (selectElement.options[0]) {
selectElement.options[0].textContent = defaultLabel;
selectElement.options[0].value = '';
}
if (typeof fetchImpl !== 'function') {
return;
}
let response;
try {
response = await fetchImpl('/api/instances', {
headers: { Accept: 'application/json' },
credentials: 'omit',
});
} catch (error) {
console.warn('Failed to load federation instances', error);
return;
}
if (!response || typeof response.json !== 'function') {
return;
}
if (!response.ok) {
return;
}
let payload;
try {
payload = await response.json();
} catch (error) {
console.warn('Invalid federation instances payload', error);
return;
}
if (!Array.isArray(payload)) {
return;
}
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
const sortedEntries = payload
.filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '')
.map(entry => ({
domain: entry.domain.trim(),
label: resolveInstanceLabel(entry),
}))
.sort((a, b) => {
const labelA = a.label || a.domain;
const labelB = b.label || b.domain;
return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' });
});
while (selectElement.options.length > 1) {
selectElement.remove(1);
}
let matchedIndex = 0;
sortedEntries.forEach((entry, index) => {
if (!doc || typeof doc.createElement !== 'function') {
return;
}
const option = doc.createElement('option');
const optionLabel = entry.label && entry.label.trim().length > 0 ? entry.label : entry.domain;
const label = optionLabel.trim();
option.value = entry.domain;
option.textContent = label;
option.dataset.instanceDomain = entry.domain;
selectElement.appendChild(option);
if (sanitizedDomain && entry.domain.toLowerCase() === sanitizedDomain) {
matchedIndex = index + 1;
}
});
if (matchedIndex > 0 && selectElement.options[matchedIndex]) {
selectElement.selectedIndex = matchedIndex;
} else {
selectElement.selectedIndex = 0;
}
const navigateTo = typeof navigate === 'function'
? navigate
: url => {
if (!url || !windowObject || !windowObject.location) {
return;
}
if (typeof windowObject.location.assign === 'function') {
windowObject.location.assign(url);
} else {
windowObject.location.href = url;
}
};
selectElement.addEventListener('change', event => {
const target = event?.target;
if (!target || typeof target.value !== 'string' || target.value.trim() === '') {
return;
}
const url = buildInstanceUrl(target.value);
if (url) {
navigateTo(url);
}
});
}
export const __test__ = { resolveInstanceLabel };
+515 -179
View File
@@ -20,13 +20,26 @@ import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
import {
TELEMETRY_FIELDS,
buildTelemetryDisplayEntries,
collectTelemetryMetrics,
fmtAlt,
fmtHumidity,
fmtPressure,
fmtTemperature,
fmtTx,
} from './short-info-telemetry.js';
import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
formatChatMessagePrefix,
formatChatChannelTag,
formatNodeAnnouncementPrefix
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -63,6 +76,7 @@ export function initializeApp(config) {
const headerTitleTextEl = headerEl ? headerEl.querySelector('.site-title-text') : null;
const chatEl = document.getElementById('chat');
const refreshInfo = document.getElementById('refreshInfo');
const instanceSelect = document.getElementById('instanceSelect');
const baseTitle = document.title;
const nodesTable = document.getElementById('nodes');
const sortButtons = nodesTable ? Array.from(nodesTable.querySelectorAll('thead .sort-button[data-sort-key]')) : [];
@@ -111,20 +125,30 @@ export function initializeApp(config) {
let allNeighbors = [];
/** @type {Map<string, Object>} */
let nodesById = new Map();
let nodesByNum = new Map();
const messageNodeHydrator = createMessageNodeHydrator({
fetchNodeById,
applyNodeFallback: applyNodeNameFallback,
logger: console,
});
/** @type {string|undefined} */
let lastChatDate;
const NODE_LIMIT = 1000;
const CHAT_LIMIT = 1000;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
const REFRESH_MS = config.refreshMs;
const CHAT_ENABLED = Boolean(config.chatEnabled);
const instanceSelectorEnabled = Boolean(config.instancesFeatureEnabled);
refreshInfo.textContent = `${config.channel} (${config.frequency}) — active nodes: …`;
if (instanceSelectorEnabled && instanceSelect) {
void initializeInstanceSelector({
selectElement: instanceSelect,
instanceDomain: config.instanceDomain,
defaultLabel: 'Select region ...',
}).catch(error => {
console.warn('Instance selector initialisation failed', error);
});
}
/** @type {ReturnType<typeof setTimeout>|null} */
let refreshTimer = null;
@@ -1624,24 +1648,17 @@ export function initializeApp(config) {
const titleAttr = safeTitle ? ` title="${safeTitle}"` : '';
const roleValue = normalizeRole(role != null && role !== '' ? role : (nodeData && nodeData.role));
let infoAttr = '';
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
battery: nodeData.battery_level ?? nodeData.battery ?? null,
voltage: nodeData.voltage ?? null,
uptime: nodeData.uptime_seconds ?? nodeData.uptime ?? null,
channel: nodeData.channel_utilization ?? nodeData.channel ?? null,
airUtil: nodeData.air_util_tx ?? nodeData.airUtil ?? null,
temperature: nodeData.temperature ?? nodeData.temp ?? null,
humidity: nodeData.relative_humidity ?? nodeData.relativeHumidity ?? nodeData.humidity ?? null,
pressure: nodeData.barometric_pressure ?? nodeData.barometricPressure ?? nodeData.pressure ?? null,
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
hwModel: nodeData.hw_model ?? nodeData.hwModel ?? '',
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
Object.assign(info, collectTelemetryMetrics(nodeData));
const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
if (attrNodeIdRaw) {
@@ -1669,14 +1686,21 @@ export function initializeApp(config) {
*/
function rebuildNodeIndex(nodes) {
nodesById = new Map();
nodesByNum = new Map();
if (!Array.isArray(nodes)) return;
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
const nodeId = typeof node.node_id === 'string'
const nodeIdRaw = typeof node.node_id === 'string'
? node.node_id
: (typeof node.nodeId === 'string' ? node.nodeId : null);
if (!nodeId) continue;
nodesById.set(nodeId, node);
if (nodeIdRaw) {
nodesById.set(nodeIdRaw.trim(), node);
}
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
if (Number.isFinite(nodeNum)) {
nodesByNum.set(nodeNum, node);
}
}
}
@@ -1849,23 +1873,6 @@ export function initializeApp(config) {
return value != null && value !== '' ? String(value) : '—';
}
/**
* Append telemetry information to the short-info overlay payload.
*
* @param {Array<string>} lines Output accumulator.
* @param {string} label Field label.
* @param {*} rawValue Raw telemetry value.
* @param {Function} formatter Optional formatter callback.
* @returns {void}
*/
function appendTelemetryLine(lines, label, rawValue, formatter) {
if (!Array.isArray(lines)) return;
if (rawValue == null || rawValue === '') return;
const formatted = formatter ? formatter(rawValue) : rawValue;
if (formatted == null || formatted === '') return;
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(formatted))}`);
}
/**
* Transform a node-shaped payload into the overlay data format.
*
@@ -1908,14 +1915,6 @@ export function initializeApp(config) {
}
const numericPairs = [
['battery', source.battery ?? source.battery_level],
['voltage', source.voltage],
['uptime', source.uptime ?? source.uptime_seconds],
['channel', source.channel ?? source.channel_utilization],
['airUtil', source.airUtil ?? source.air_util_tx],
['temperature', source.temperature],
['humidity', source.humidity ?? source.relative_humidity],
['pressure', source.pressure ?? source.barometric_pressure],
['telemetryTime', source.telemetryTime ?? source.telemetry_time],
['lastHeard', source.lastHeard ?? source.last_heard],
['latitude', source.latitude],
@@ -1931,6 +1930,14 @@ export function initializeApp(config) {
}
}
const telemetryMetrics = collectTelemetryMetrics(source);
for (const field of TELEMETRY_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(telemetryMetrics, field.key)) {
continue;
}
normalized[field.key] = telemetryMetrics[field.key];
}
const lastSeenRaw = source.lastSeenIso ?? source.last_seen_iso;
if (typeof lastSeenRaw === 'string' && lastSeenRaw.trim().length > 0) {
normalized.lastSeenIso = lastSeenRaw.trim();
@@ -2040,14 +2047,10 @@ export function initializeApp(config) {
if (modelValue) {
lines.push(`Model: ${escapeHtml(modelValue)}`);
}
appendTelemetryLine(lines, 'Battery', overlayInfo.battery, value => fmtAlt(value, '%'));
appendTelemetryLine(lines, 'Voltage', overlayInfo.voltage, value => fmtAlt(value, 'V'));
appendTelemetryLine(lines, 'Uptime', overlayInfo.uptime, formatShortInfoUptime);
appendTelemetryLine(lines, 'Channel Util', overlayInfo.channel, fmtTx);
appendTelemetryLine(lines, 'Air Util Tx', overlayInfo.airUtil, fmtTx);
appendTelemetryLine(lines, 'Temperature', overlayInfo.temperature, fmtTemperature);
appendTelemetryLine(lines, 'Humidity', overlayInfo.humidity, fmtHumidity);
appendTelemetryLine(lines, 'Pressure', overlayInfo.pressure, fmtPressure);
const telemetryEntries = buildTelemetryDisplayEntries(overlayInfo, { formatUptime: formatShortInfoUptime });
for (const entry of telemetryEntries) {
lines.push(`${escapeHtml(entry.label)}: ${escapeHtml(entry.value)}`);
}
if (neighborLineHtml) {
lines.push(neighborLineHtml);
}
@@ -2092,20 +2095,23 @@ export function initializeApp(config) {
* @param {number} ts Unix timestamp in seconds.
* @returns {HTMLElement} Divider element.
*/
function maybeCreateDateDivider(ts) {
if (!ts) return null;
const d = new Date(ts * 1000);
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (lastChatDate !== key) {
lastChatDate = key;
const midnight = new Date(d);
midnight.setHours(0, 0, 0, 0);
const div = document.createElement('div');
div.className = 'chat-entry-date';
div.textContent = `-- ${formatDate(midnight)} --`;
return div;
}
return null;
function createDateDividerFactory() {
let lastChatDate = null;
return ts => {
if (!ts) return null;
const d = new Date(ts * 1000);
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (lastChatDate !== key) {
lastChatDate = key;
const midnight = new Date(d);
midnight.setHours(0, 0, 0, 0);
const div = document.createElement('div');
div.className = 'chat-entry-date';
div.textContent = `-- ${formatDate(midnight)} --`;
return div;
}
return null;
};
}
/**
@@ -2114,21 +2120,354 @@ export function initializeApp(config) {
* @param {Object} n Node payload.
* @returns {HTMLElement} Chat log element.
*/
function createNodeChatEntry(n) {
function createNodeChatEntry(node, timestampOverride = null) {
if (!node || typeof node !== 'object') return null;
const nodeIdRaw = pickFirstProperty([node], ['node_id', 'nodeId']);
const fallbackId = nodeIdRaw || 'Unknown node';
const longNameRaw = pickFirstProperty([node], ['long_name', 'longName']);
const longNameDisplay = longNameRaw ? String(longNameRaw) : fallbackId;
const shortNameRaw = pickFirstProperty([node], ['short_name', 'shortName']);
const shortNameDisplay = shortNameRaw ? String(shortNameRaw) : (nodeIdRaw ? nodeIdRaw.slice(-4) : null);
const roleDisplay = pickFirstProperty([node], ['role']);
const tsSeconds = timestampOverride != null
? timestampOverride
: resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
return createAnnouncementEntry({
timestampSeconds: tsSeconds,
shortName: shortNameDisplay,
longName: longNameDisplay,
role: roleDisplay,
metadataSource: node,
nodeData: node,
messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy(`New node: ${longNameDisplay}`)}`
});
}
/**
* Build a formatted suffix that enumerates highlight values.
*
* @param {Array<{label: string, value: string}>} highlights Highlight metadata entries.
* @returns {string} HTML suffix containing escaped highlight entries.
*/
function buildHighlightSuffix(highlights) {
if (!Array.isArray(highlights) || highlights.length === 0) {
return '';
}
const parts = [];
for (const entry of highlights) {
if (!entry || typeof entry !== 'object') {
continue;
}
const { label, value } = entry;
if (label == null || value == null || value === '') {
continue;
}
const labelText = String(label).trim();
const valueText = String(value).trim();
if (!labelText || !valueText) {
continue;
}
parts.push(`${escapeHtml(labelText)}: ${escapeHtml(valueText)}`);
}
if (!parts.length) {
return '';
}
return `${parts.join(', ')}`;
}
/**
* Render a non-italicised emoji span suitable for announcement entries.
*
* @param {string} symbol Emoji or short textual marker.
* @returns {string} HTML span wrapping the escaped symbol.
*/
function renderEmojiHtml(symbol) {
if (symbol == null) {
return '';
}
const trimmed = String(symbol).trim();
if (!trimmed) {
return '';
}
return `<span class="chat-entry-emoji" aria-hidden="true">${escapeHtml(trimmed)}</span>`;
}
/**
* Render chat announcement copy without italic styling.
*
* @param {string} baseText Base message content before any suffix.
* @param {string} [suffix=''] Optional HTML-safe suffix appended to the base copy.
* @returns {string} Escaped HTML span containing the announcement copy.
*/
function renderAnnouncementCopy(baseText, suffix = '') {
const safeBase = baseText != null ? String(baseText) : '';
const safeSuffix = suffix != null ? String(suffix) : '';
return `<span class="chat-entry-copy">${escapeHtml(safeBase)}${safeSuffix}</span>`;
}
function createNodeInfoChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('💾')} ${renderAnnouncementCopy('Updated node info')}`
});
}
function createTelemetryChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const highlightSuffix = buildHighlightSuffix(formatTelemetryHighlights(entry?.telemetry));
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('🔋')} ${renderAnnouncementCopy('Broadcasted telemetry', highlightSuffix)}`
});
}
function createPositionChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const highlightSuffix = buildHighlightSuffix(formatPositionHighlights(entry?.position));
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('📍')} ${renderAnnouncementCopy('Broadcasted position info', highlightSuffix)}`
});
}
function createNeighborChatEntry(entry, context) {
const label = context.longName ? String(context.longName) : (context.nodeId || 'Unknown node');
const neighborId = entry?.neighborId ?? pickFirstProperty([entry?.neighbor], ['neighbor_id', 'neighborId']);
let neighborLabel = null;
if (neighborId) {
const trimmed = String(neighborId).trim();
if (trimmed && nodesById.has(trimmed)) {
const neighborNode = nodesById.get(trimmed);
neighborLabel = pickFirstProperty([neighborNode], ['long_name', 'longName', 'short_name', 'shortName']) ?? trimmed;
} else {
neighborLabel = trimmed;
}
}
const detail = neighborLabel ? `: ${escapeHtml(String(neighborLabel))}` : '';
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: label,
role: context.role,
metadataSource: context.metadataSource,
nodeData: context.nodeData,
messageHtml: `${renderEmojiHtml('🏘️')} ${renderAnnouncementCopy('Broadcasted neighbor info', detail)}`
});
}
function createChatLogEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
if (entry.type === CHAT_LOG_ENTRY_TYPES.NODE_NEW) {
return createNodeChatEntry(entry.node ?? resolveNodeForLogEntry(entry) ?? null, entry?.ts ?? null);
}
const context = buildDisplayContext(entry);
switch (entry.type) {
case CHAT_LOG_ENTRY_TYPES.NODE_INFO:
return createNodeInfoChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.TELEMETRY:
return createTelemetryChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.POSITION:
return createPositionChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
return createNeighborChatEntry(entry, context);
default:
return null;
}
}
/**
* Create a consistently formatted chat log entry for node-centric events.
*
* @param {{
* timestampSeconds: ?number,
* shortName: ?string,
* longName: ?string,
* role: ?string,
* metadataSource: Object|null,
* nodeData: Object|null,
* messageHtml: string
* }} params Rendering parameters.
* @returns {HTMLElement} Chat log element.
*/
function createAnnouncementEntry({
timestampSeconds,
shortName,
longName,
role,
metadataSource,
nodeData,
messageHtml
}) {
const div = document.createElement('div');
const ts = formatTime(new Date(n.first_heard * 1000));
div.className = 'chat-entry-node';
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
const longName = escapeHtml(n.long_name || '');
const metadata = extractChatMessageMetadata(n);
const tsDate = timestampSeconds != null ? new Date(timestampSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const metadata = extractChatMessageMetadata(metadataSource || nodeData || {});
const prefix = formatNodeAnnouncementPrefix({
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
div.innerHTML = `${prefix} ${short} <em>New node: ${longName}</em>`;
const longNameDisplay = longName != null ? String(longName) : '';
const shortHtml = renderShortHtml(shortName, role, longNameDisplay, nodeData || metadataSource || {});
div.className = 'chat-entry-node';
div.innerHTML = `${prefix} ${shortHtml} ${messageHtml}`;
return div;
}
/**
* Derive display context for a chat log entry by inspecting node payloads.
*
* @param {Object} entry Chat log entry payload.
* @returns {{
* nodeId: ?string,
* nodeNum: ?number,
* shortName: ?string,
* longName: ?string,
* role: ?string,
* metadataSource: Object|null,
* nodeData: Object|null
* }} Normalised display metadata.
*/
function buildDisplayContext(entry) {
const resolvedNode = resolveNodeForLogEntry(entry);
const candidateSources = [resolvedNode, entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]
.filter(source => source && typeof source === 'object');
const nodeId = typeof entry?.nodeId === 'string' && entry.nodeId.trim().length
? entry.nodeId.trim()
: pickFirstProperty(candidateSources, ['node_id', 'nodeId']);
const nodeNum = Number.isFinite(entry?.nodeNum)
? entry.nodeNum
: pickNumericProperty(candidateSources, ['node_num', 'nodeNum', 'num']);
let shortName = pickFirstProperty(candidateSources, ['short_name', 'shortName']);
if ((!shortName || String(shortName).trim().length === 0) && nodeId) {
shortName = nodeId.slice(-4);
}
let longName = pickFirstProperty(candidateSources, ['long_name', 'longName']);
if ((!longName || String(longName).trim().length === 0) && nodeId) {
longName = nodeId;
}
const role = pickFirstProperty(candidateSources, ['role']);
const metadataSource = resolvedNode || candidateSources[0] || {};
const nodeData = resolvedNode || candidateSources[0] || {};
return { nodeId, nodeNum, shortName, longName, role, metadataSource, nodeData };
}
/**
* Locate the canonical node object associated with a chat log entry.
*
* @param {Object} entry Chat log entry payload.
* @returns {?Object} Matched node payload when available.
*/
function resolveNodeForLogEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
if (entry.node && typeof entry.node === 'object') {
return entry.node;
}
const idCandidates = [];
if (typeof entry?.nodeId === 'string') {
idCandidates.push(entry.nodeId);
}
for (const source of [entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]) {
const candidate = pickFirstProperty([source], ['node_id', 'nodeId']);
if (candidate) {
idCandidates.push(candidate);
}
}
for (const rawId of idCandidates) {
const trimmed = typeof rawId === 'string' ? rawId.trim() : String(rawId);
if (trimmed && nodesById.has(trimmed)) {
return nodesById.get(trimmed);
}
}
const numCandidates = [];
if (Number.isFinite(entry?.nodeNum)) {
numCandidates.push(entry.nodeNum);
}
for (const source of [entry?.node, entry?.telemetry, entry?.position, entry?.neighbor]) {
const candidate = pickNumericProperty([source], ['node_num', 'nodeNum', 'num']);
if (Number.isFinite(candidate)) {
numCandidates.push(candidate);
}
}
for (const num of numCandidates) {
if (Number.isFinite(num) && nodesByNum.has(num)) {
return nodesByNum.get(num);
}
}
return null;
}
/**
* Retrieve the first present property value from a collection of objects.
*
* @param {Array<Object>} sources Candidate objects.
* @param {Array<string>} keys Ordered property names to inspect.
* @returns {*} First present non-blank value or ``null`` when absent.
*/
function pickFirstProperty(sources, keys) {
if (!Array.isArray(sources) || !Array.isArray(keys)) {
return null;
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const value = source[key];
if (value == null) continue;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) {
continue;
}
return trimmed;
}
return value;
}
}
return null;
}
/**
* Retrieve the first finite numeric property from candidate objects.
*
* @param {Array<Object>} sources Candidate objects.
* @param {Array<string>} keys Ordered property names to inspect.
* @returns {?number} First finite number when available.
*/
function pickNumericProperty(sources, keys) {
if (!Array.isArray(sources) || !Array.isArray(keys)) {
return null;
}
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
const raw = source[key];
if (raw == null || raw === '') continue;
const num = typeof raw === 'number' ? raw : Number(raw);
if (Number.isFinite(num)) {
return num;
}
}
}
return null;
}
/**
* Build a chat log entry for a text message.
*
@@ -2137,7 +2476,12 @@ export function initializeApp(config) {
*/
function createMessageChatEntry(m) {
const div = document.createElement('div');
const ts = formatTime(new Date(m.rx_time * 1000));
const tsSeconds = resolveTimestampSeconds(
m.rx_time ?? m.rxTime,
m.rx_iso ?? m.rxIso
);
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
const text = escapeHtml(m.text || '');
const metadata = extractChatMessageMetadata(m);
@@ -2145,11 +2489,8 @@ export function initializeApp(config) {
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
const channelTag = formatChatChannelTag({
channelName: metadata.channelName ? escapeHtml(metadata.channelName) : ''
});
div.className = 'chat-entry-msg';
div.innerHTML = `${prefix} ${short} ${channelTag} ${text}`;
div.innerHTML = `${prefix} ${short} ${text}`;
return div;
}
@@ -2160,47 +2501,96 @@ export function initializeApp(config) {
* @param {Array<Object>} messages Collection of message payloads.
* @returns {void}
*/
function renderChatLog(nodes, messages) {
function renderChatLog({
nodes = [],
messages = [],
telemetryEntries = [],
positionEntries = [],
neighborEntries = []
}) {
if (!CHAT_ENABLED || !chatEl) return;
const entries = [];
for (const n of nodes || []) {
entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
}
for (const m of messages || []) {
if (!m || m.encrypted) continue;
entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
}
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoff = nowSeconds - CHAT_RECENT_WINDOW_SECONDS;
const recentEntries = entries.filter(entry => {
if (entry == null) return false;
const rawTs = entry.ts;
if (rawTs == null) return false;
const ts = typeof rawTs === 'number' ? rawTs : Number(rawTs);
if (!Number.isFinite(ts)) return false;
entry.ts = ts;
return ts >= cutoff;
const { logEntries, channels } = buildChatTabModel({
nodes,
telemetry: telemetryEntries,
positions: positionEntries,
neighbors: neighborEntries,
messages,
nowSeconds,
windowSeconds: CHAT_RECENT_WINDOW_SECONDS,
maxChannelIndex: MAX_CHANNEL_INDEX
});
recentEntries.sort((a, b) => {
if (a.ts !== b.ts) return a.ts - b.ts;
return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
const logContent = buildChatFragment({
entries: logEntries,
renderEntry: createChatLogEntry,
emptyLabel: 'No recent mesh activity.'
});
const frag = document.createDocumentFragment();
lastChatDate = null;
for (const entry of recentEntries) {
const divider = maybeCreateDateDivider(entry.ts);
if (divider) frag.appendChild(divider);
if (entry.type === 'node') {
frag.appendChild(createNodeChatEntry(entry.item));
} else {
frag.appendChild(createMessageChatEntry(entry.item));
const channelTabs = channels.map(channel => ({
id: `channel-${channel.index}`,
label: channel.label,
content: buildChatFragment({
entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })),
renderEntry: entry => createMessageChatEntry(entry.item),
emptyLabel: 'No messages on this channel.'
})
}));
const tabs = [
{ id: 'log', label: 'Log', content: logContent },
...channelTabs
];
const previousActive = chatEl.dataset?.activeTab || null;
const defaultActive = channelTabs.find(tab => tab.id === 'channel-0')?.id || channelTabs[0]?.id || 'log';
renderChatTabs({
document,
container: chatEl,
tabs,
previousActiveTabId: previousActive,
defaultActiveTabId: defaultActive
});
}
/**
* Construct a document fragment for chat entries, inserting date dividers
* and optional empty-state labels.
*
* @param {{
* entries: Array<{ ts: number, item: Object }>,
* renderEntry: Function,
* emptyLabel?: string
* }} params Fragment construction parameters.
* @returns {DocumentFragment} Populated fragment.
*/
function buildChatFragment({ entries = [], renderEntry, emptyLabel }) {
const fragment = document.createDocumentFragment();
if (!entries || entries.length === 0) {
if (emptyLabel) {
const empty = document.createElement('p');
empty.className = 'chat-empty';
empty.textContent = emptyLabel;
fragment.appendChild(empty);
}
return fragment;
}
const getDivider = createDateDividerFactory();
const limitedEntries = entries.slice(Math.max(entries.length - CHAT_LIMIT, 0));
for (const entry of limitedEntries) {
if (!entry || typeof entry.ts !== 'number') {
continue;
}
const divider = getDivider(entry.ts);
if (divider) fragment.appendChild(divider);
if (typeof renderEntry === 'function') {
const node = renderEntry(entry);
if (node) {
fragment.appendChild(node);
}
}
}
chatEl.replaceChildren(frag);
while (chatEl.childElementCount > CHAT_LIMIT) {
chatEl.removeChild(chatEl.firstChild);
}
chatEl.scrollTop = chatEl.scrollHeight;
return fragment;
}
/**
@@ -2258,66 +2648,6 @@ export function initializeApp(config) {
return Number.isFinite(n) ? n.toFixed(d) : "";
}
/**
* Format altitude values with units.
*
* @param {*} v Raw altitude value.
* @param {string} s Unit suffix.
* @returns {string} Altitude string.
*/
function fmtAlt(v, s) {
return (v == null || v === '') ? "" : `${v}${s}`;
}
/**
* Format transmission utilisation values as percentages.
*
* @param {*} v Raw utilisation value.
* @param {number} [d=3] Decimal precision.
* @returns {string} Percentage string.
*/
function fmtTx(v, d = 3) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(d)}%` : "";
}
/**
* Format temperature telemetry with a degree suffix.
*
* @param {*} v Raw temperature value.
* @returns {string} Temperature string.
*/
function fmtTemperature(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)}°C` : "";
}
/**
* Format humidity telemetry as a percentage.
*
* @param {*} v Raw humidity value.
* @returns {string} Humidity string.
*/
function fmtHumidity(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)}%` : "";
}
/**
* Format barometric pressure telemetry in hPa.
*
* @param {*} v Raw pressure value.
* @returns {string} Pressure string.
*/
function fmtPressure(v) {
if (v == null || v === '') return "";
const n = Number(v);
return Number.isFinite(n) ? `${n.toFixed(1)} hPa` : "";
}
/**
* Format SNR readings with a ``dB`` suffix.
*
@@ -3086,7 +3416,13 @@ export function initializeApp(config) {
allNodes = nodes;
rebuildNodeIndex(allNodes);
const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById);
renderChatLog(nodes, chatMessages);
renderChatLog({
nodes,
messages: chatMessages,
telemetryEntries,
positionEntries: positions,
neighborEntries: neighborTuples
});
allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];
applyFilter();
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
@@ -0,0 +1,421 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Determine whether ``value`` can be treated as a finite number.
*
* @param {*} value Candidate numeric value.
* @returns {boolean} ``true`` when the value parses to a finite number.
*/
function isFiniteNumber(value) {
if (value == null || value === '') return false;
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number);
}
/**
* Retrieve the first defined property from ``container`` using ``keys``.
*
* @param {Object} container Object inspected for values.
* @param {Array<string>} keys Candidate property names.
* @returns {*} First non-nullish value discovered.
*/
function pickFirstValue(container, keys) {
if (!container || typeof container !== 'object') {
return undefined;
}
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(container, key)) {
const candidate = container[key];
if (candidate != null && (candidate !== '' || candidate === 0)) {
return candidate;
}
}
}
return undefined;
}
/**
* Format arbitrary telemetry values using a numeric suffix.
*
* @param {*} value Raw value to format.
* @param {string} suffix Unit suffix appended when formatting succeeds.
* @returns {string} Formatted value or an empty string for invalid input.
*/
export function fmtAlt(value, suffix) {
if (!isFiniteNumber(value) && !(value === 0 || value === '0')) {
return '';
}
return `${Number(value)}${suffix}`;
}
/**
* Format utilisation metrics as percentages.
*
* @param {*} value Raw utilisation value.
* @param {number} [decimals=3] Decimal precision applied to the percentage.
* @returns {string} Formatted percentage string.
*/
export function fmtTx(value, decimals = 3) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(decimals)}%`;
}
/**
* Format temperature telemetry in degrees Celsius.
*
* @param {*} value Raw temperature reading.
* @returns {string} Formatted temperature string.
*/
export function fmtTemperature(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)}°C`;
}
/**
* Format relative humidity telemetry as a percentage.
*
* @param {*} value Raw humidity reading.
* @returns {string} Formatted humidity string.
*/
export function fmtHumidity(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)}%`;
}
/**
* Format barometric pressure telemetry in hectopascals.
*
* @param {*} value Raw pressure value.
* @returns {string} Formatted pressure string.
*/
export function fmtPressure(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} hPa`;
}
/**
* Format current telemetry, automatically scaling to milliamperes when
* appropriate.
*
* @param {*} value Raw current reading expressed in amperes.
* @returns {string} Formatted current string.
*/
export function fmtCurrent(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
if (Math.abs(num) < 1) {
return `${(num * 1000).toFixed(1)} mA`;
}
return `${num.toFixed(2)} A`;
}
/**
* Format gas resistance telemetry using a human readable Ohm prefix.
*
* @param {*} value Raw resistance value expressed in Ohms.
* @returns {string} Formatted resistance string.
*/
export function fmtGasResistance(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
const absVal = Math.abs(num);
if (absVal >= 1_000_000) {
return `${(num / 1_000_000).toFixed(2)}`;
}
if (absVal >= 1_000) {
return `${(num / 1_000).toFixed(2)}`;
}
return `${num.toFixed(2)} Ω`;
}
/**
* Format generic distance telemetry in metres.
*
* @param {*} value Raw distance value.
* @returns {string} Formatted distance string.
*/
export function fmtDistance(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} m`;
}
/**
* Format optical telemetry in lux.
*
* @param {*} value Raw lux reading.
* @returns {string} Formatted lux string.
*/
export function fmtLux(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} lx`;
}
/**
* Format wind direction telemetry in degrees.
*
* @param {*} value Raw wind direction reading.
* @returns {string} Formatted wind direction string.
*/
export function fmtWindDirection(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}°`;
}
/**
* Format wind speed telemetry in metres per second.
*
* @param {*} value Raw wind speed reading.
* @returns {string} Formatted wind speed string.
*/
export function fmtWindSpeed(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(1)} m/s`;
}
/**
* Format weight telemetry in kilograms.
*
* @param {*} value Raw weight value.
* @returns {string} Formatted weight string.
*/
export function fmtWeight(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} kg`;
}
/**
* Format radiation telemetry using microsieverts per hour.
*
* @param {*} value Raw radiation value.
* @returns {string} Formatted radiation string.
*/
export function fmtRadiation(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} µSv/h`;
}
/**
* Format rainfall telemetry using millimetres.
*
* @param {*} value Raw rainfall accumulation value.
* @returns {string} Formatted rainfall string.
*/
export function fmtRainfall(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${num.toFixed(2)} mm`;
}
/**
* Format soil moisture telemetry. The metrics are typically raw sensor values
* without defined units, therefore the raw integer is surfaced unchanged.
*
* @param {*} value Raw soil moisture reading.
* @returns {string} Soil moisture string.
*/
export function fmtSoilMoisture(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}`;
}
/**
* Format soil temperature telemetry in degrees Celsius.
*
* @param {*} value Raw soil temperature reading.
* @returns {string} Formatted soil temperature string.
*/
export function fmtSoilTemperature(value) {
return fmtTemperature(value);
}
/**
* Format indoor air quality index values.
*
* @param {*} value Raw IAQ reading.
* @returns {string} IAQ string.
*/
export function fmtIaq(value) {
if (!isFiniteNumber(value)) return '';
const num = Number(value);
return `${Math.round(num)}`;
}
/**
* Telemetry descriptors consumed by the short-info overlay.
*
* Each descriptor includes a canonical key, display label, candidate source
* property names, and a formatter that converts numeric values into a human
* readable string.
*/
export const TELEMETRY_FIELDS = [
{ key: 'battery', label: 'Battery', sources: ['battery', 'battery_level', 'batteryLevel'], formatter: value => fmtAlt(value, '%') },
{ key: 'voltage', label: 'Voltage', sources: ['voltage'], formatter: value => fmtAlt(value, 'V') },
{ key: 'current', label: 'Current', sources: ['current'], formatter: fmtCurrent },
{ key: 'uptime', label: 'Uptime', sources: ['uptime', 'uptime_seconds', 'uptimeSeconds'], formatter: (value, utils) => (typeof utils.formatUptime === 'function' ? utils.formatUptime(value) : '') },
{
key: 'channel',
label: 'Channel Util',
sources: ['channel_utilization', 'channelUtilization', 'channel'],
formatter: value => fmtTx(value),
},
{
key: 'airUtil',
label: 'Air Util Tx',
sources: ['airUtil', 'air_util_tx', 'airUtilTx'],
formatter: value => fmtTx(value),
},
{ key: 'temperature', label: 'Temperature', sources: ['temperature', 'temp'], formatter: fmtTemperature },
{ key: 'humidity', label: 'Humidity', sources: ['humidity', 'relative_humidity', 'relativeHumidity'], formatter: fmtHumidity },
{ key: 'pressure', label: 'Pressure', sources: ['pressure', 'barometric_pressure', 'barometricPressure'], formatter: fmtPressure },
{ key: 'gasResistance', label: 'Gas Resistance', sources: ['gas_resistance', 'gasResistance'], formatter: fmtGasResistance },
{ key: 'iaq', label: 'IAQ', sources: ['iaq'], formatter: fmtIaq },
{ key: 'distance', label: 'Distance', sources: ['distance'], formatter: fmtDistance },
{ key: 'lux', label: 'Lux', sources: ['lux'], formatter: fmtLux },
{ key: 'whiteLux', label: 'White Lux', sources: ['white_lux', 'whiteLux'], formatter: fmtLux },
{ key: 'irLux', label: 'IR Lux', sources: ['ir_lux', 'irLux'], formatter: fmtLux },
{ key: 'uvLux', label: 'UV Lux', sources: ['uv_lux', 'uvLux'], formatter: fmtLux },
{ key: 'windDirection', label: 'Wind Direction', sources: ['wind_direction', 'windDirection'], formatter: fmtWindDirection },
{ key: 'windSpeed', label: 'Wind Speed', sources: ['wind_speed', 'windSpeed', 'windSpeedMps'], formatter: fmtWindSpeed },
{ key: 'windGust', label: 'Wind Gust', sources: ['wind_gust', 'windGust'], formatter: fmtWindSpeed },
{ key: 'windLull', label: 'Wind Lull', sources: ['wind_lull', 'windLull'], formatter: fmtWindSpeed },
{ key: 'weight', label: 'Weight', sources: ['weight'], formatter: fmtWeight },
{ key: 'radiation', label: 'Radiation', sources: ['radiation', 'radiationLevel'], formatter: fmtRadiation },
{ key: 'rainfall1h', label: 'Rainfall 1h', sources: ['rainfall_1h', 'rainfall1h', 'rainfall1H'], formatter: fmtRainfall },
{ key: 'rainfall24h', label: 'Rainfall 24h', sources: ['rainfall_24h', 'rainfall24h', 'rainfall24H'], formatter: fmtRainfall },
{ key: 'soilMoisture', label: 'Soil Moisture', sources: ['soil_moisture', 'soilMoisture'], formatter: fmtSoilMoisture },
{ key: 'soilTemperature', label: 'Soil Temperature', sources: ['soil_temperature', 'soilTemperature'], formatter: fmtSoilTemperature },
];
/**
* Collect telemetry metrics from arbitrary node payloads.
*
* The function inspects common top-level, device metric, and environment
* metric collections in order to surface numeric telemetry values.
*
* @param {*} source Node payload that may contain telemetry.
* @returns {Object} Object containing numeric telemetry keyed by descriptor.
*/
export function collectTelemetryMetrics(source) {
const metrics = {};
if (!source || typeof source !== 'object') {
return metrics;
}
const potentialContainers = [
source.telemetry,
source.device_metrics,
source.deviceMetrics,
source.environment_metrics,
source.environmentMetrics,
source.raw && typeof source.raw === 'object' ? source.raw.device_metrics : null,
source.raw && typeof source.raw === 'object' ? source.raw.deviceMetrics : null,
source,
];
const containers = [];
for (const container of potentialContainers) {
if (!container || typeof container !== 'object') {
continue;
}
if (!containers.includes(container)) {
containers.push(container);
}
}
for (const field of TELEMETRY_FIELDS) {
const keys = Array.isArray(field.sources) && field.sources.length > 0
? field.sources
: [field.key];
for (const container of containers) {
const raw = pickFirstValue(container, keys);
if (!isFiniteNumber(raw) && !(raw === 0 || raw === '0')) {
continue;
}
const num = Number(raw);
if (Number.isFinite(num)) {
metrics[field.key] = num;
break;
}
}
}
return metrics;
}
/**
* Build display entries for telemetry values suitable for short-info overlays.
*
* @param {Object} telemetry Telemetry metrics keyed by descriptor ``key``.
* @param {{formatUptime?: Function}} [utils] Optional formatter overrides.
* @returns {Array<{label: string, value: string}>} Renderable telemetry entries.
*/
export function buildTelemetryDisplayEntries(telemetry, utils = {}) {
const entries = [];
if (!telemetry || typeof telemetry !== 'object') {
return entries;
}
for (const field of TELEMETRY_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(telemetry, field.key)) {
continue;
}
const value = telemetry[field.key];
if (value == null) {
continue;
}
const formatted = typeof field.formatter === 'function'
? field.formatter(value, utils)
: String(value);
if (formatted == null || formatted === '') {
continue;
}
entries.push({ label: field.label, value: formatted });
}
return entries;
}
export default {
TELEMETRY_FIELDS,
collectTelemetryMetrics,
buildTelemetryDisplayEntries,
fmtAlt,
fmtTx,
fmtTemperature,
fmtHumidity,
fmtPressure,
fmtCurrent,
fmtGasResistance,
fmtDistance,
fmtLux,
fmtWindDirection,
fmtWindSpeed,
fmtWeight,
fmtRadiation,
fmtRainfall,
fmtSoilMoisture,
fmtSoilTemperature,
fmtIaq,
};
+159 -7
View File
@@ -131,7 +131,15 @@ body {
}
h1 {
margin: 0 0 8px;
margin: 0;
}
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.site-title {
@@ -152,6 +160,63 @@ h1 {
margin-bottom: 12px;
}
.instance-selector {
display: flex;
align-items: center;
}
.instance-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: var(--input-bg);
color: var(--input-fg);
border: 1px solid var(--input-border);
border-radius: 8px;
padding: 6px 32px 6px 12px;
font-size: 14px;
line-height: 1.4;
min-width: 220px;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 18px) calc(50% - 4px), calc(100% - 12px) calc(50% - 4px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.instance-select:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 900px) {
.site-header {
flex-direction: column;
align-items: flex-start;
}
.instance-selector,
.instance-select {
width: 100%;
}
.instance-select {
min-width: 0;
}
}
.pill {
display: inline-block;
padding: 2px 8px;
@@ -405,9 +470,58 @@ th {
height: 60vh;
border: 1px solid #ddd;
border-radius: 8px;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px;
background: var(--bg2);
}
.chat-tablist {
display: flex;
gap: 4px;
padding: 6px 6px 0;
border-bottom: 1px solid var(--line);
}
.chat-tab {
flex: 1;
border: none;
background: transparent;
color: inherit;
padding: 6px 8px;
border-radius: 6px 6px 0 0;
cursor: pointer;
font-size: 12px;
transition: background-color 120ms ease, color 120ms ease;
}
.chat-tab:is(:focus-visible, :hover) {
background: rgba(0, 0, 0, 0.06);
}
.chat-tab.is-active {
background: var(--bg2);
color: var(--accent);
border-bottom: 2px solid var(--accent);
}
.chat-tabpanels {
flex: 1;
display: flex;
min-height: 0;
}
.chat-tabpanel {
flex: 1;
padding: 6px;
overflow-y: auto;
}
.chat-empty {
margin: 12px 0;
color: var(--muted);
font-style: italic;
}
.chat-entry-node {
@@ -415,6 +529,10 @@ th {
color: #555;
}
.chat-entry-copy {
font-style: normal;
}
.chat-entry-msg {
font-family: ui-monospace, Menlo, Consolas, monospace;
}
@@ -565,7 +683,7 @@ body.dark .filter-clear:hover {
outline-offset: 2px;
}
button {
button:not(.chat-tab):not(.sort-button) {
padding: 6px 10px;
border: 1px solid #ccc;
background: #fff;
@@ -574,7 +692,17 @@ button {
color: var(--fg);
}
button:hover {
.icon-button {
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
button:not(.chat-tab):not(.sort-button):hover {
background: #f6f6f6;
}
@@ -1026,17 +1154,41 @@ body.dark #chat {
color: #eee;
}
body.dark .chat-tablist {
border-bottom-color: rgba(255, 255, 255, 0.18);
}
body.dark .chat-tab {
color: #ddd;
background: transparent;
border-color: transparent;
}
body.dark .chat-tab:is(:focus-visible, :hover) {
background: rgba(255, 255, 255, 0.1);
}
body.dark .chat-tab.is-active {
background: #222;
color: var(--accent);
border-bottom-color: var(--accent);
}
body.dark .chat-empty {
color: #888;
}
body.dark th {
background: #222;
}
body.dark button {
body.dark button:not(.chat-tab):not(.sort-button) {
background: #333;
border-color: #444;
color: #eee;
}
body.dark button:hover {
body.dark button:not(.chat-tab):not(.sort-button):hover {
background: #444;
}
+147 -14
View File
@@ -15,10 +15,25 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
const coverageDir = 'coverage';
const reportsDir = 'reports';
const outputPath = path.join(reportsDir, 'javascript-coverage.json');
import istanbulLibCoverage from 'istanbul-lib-coverage';
import istanbulLibReport from 'istanbul-lib-report';
import istanbulReports from 'istanbul-reports';
import v8toIstanbul from 'v8-to-istanbul';
const { createCoverageMap } = istanbulLibCoverage;
const { createContext } = istanbulLibReport;
const coverageDir = path.resolve('coverage');
const reportsDir = path.resolve('reports');
const jsonOutputName = 'javascript-coverage.json';
const lcovOutputName = 'javascript-coverage.lcov';
const projectRoot = process.cwd();
/**
* Ensure the reports directory exists so that coverage artefacts can be written.
*
* @returns {Promise<void>} A promise that resolves when the directory is available.
*/
async function ensureReportsDir() {
try {
await fs.mkdir(reportsDir, { recursive: true });
@@ -28,32 +43,150 @@ async function ensureReportsDir() {
}
}
async function copyLatestCoverage() {
/**
* Read the coverage directory and return a deterministically ordered list of JSON files.
*
* @returns {Promise<string[]>} The absolute paths of available coverage JSON artefacts.
*/
async function listCoverageFiles() {
let entries;
try {
entries = await fs.readdir(coverageDir);
} catch (error) {
if (error.code === 'ENOENT') {
console.warn('Coverage directory not found; skipping export.');
return;
return [];
}
throw error;
}
const coverageFiles = entries.filter(name => name.endsWith('.json'));
const coverageFiles = entries
.filter(name => name.endsWith('.json'))
.map(name => path.join(coverageDir, name))
.sort();
if (!coverageFiles.length) {
console.warn('No coverage files generated; skipping export.');
return;
return [];
}
// Sort to pick the most recent entry deterministically.
coverageFiles.sort();
const latest = coverageFiles[coverageFiles.length - 1];
const source = path.join(coverageDir, latest);
return coverageFiles;
}
await fs.copyFile(source, outputPath);
console.log(`Copied coverage report to ${outputPath}`);
/**
* Convert a V8 coverage URL to a project-local filesystem path.
*
* @param {string | undefined} url The coverage URL emitted by V8.
* @returns {string | null} A normalised absolute path, or null when the URL should be ignored.
*/
function normaliseFileUrl(url) {
if (!url || url.startsWith('node:')) {
return null;
}
if (!url.startsWith('file://')) {
return null;
}
let filePath;
try {
filePath = decodeURIComponent(new URL(url).pathname);
} catch {
return null;
}
if (!filePath.startsWith(projectRoot)) {
return null;
}
if (filePath.includes('node_modules')) {
return null;
}
return filePath;
}
/**
* Transform the raw V8 coverage reports into an Istanbul coverage map.
*
* @param {string[]} coverageFiles A list of coverage artefacts to consume.
* @returns {Promise<import('istanbul-lib-coverage').CoverageMap>} The aggregated coverage map.
*/
async function buildCoverageMap(coverageFiles) {
const coverageMap = createCoverageMap({});
for (const file of coverageFiles) {
const raw = await fs.readFile(file, 'utf8');
const parsed = JSON.parse(raw);
const entries = Array.isArray(parsed.result) ? parsed.result : [];
for (const entry of entries) {
const { url, functions } = entry;
const filePath = normaliseFileUrl(url);
if (!filePath) {
continue;
}
try {
const converter = v8toIstanbul(filePath, 0, {
source: await fs.readFile(filePath, 'utf8'),
});
await converter.load();
converter.applyCoverage(functions);
const fileCoverages = converter.toIstanbul();
for (const coverage of Object.values(fileCoverages)) {
if (coverage.path) {
const relativePath = path.relative(projectRoot, coverage.path);
coverage.path = relativePath || coverage.path;
}
try {
const existingCoverage = coverageMap.fileCoverageFor(coverage.path);
existingCoverage.merge(coverage);
} catch (error) {
if (error && typeof error.message === 'string' && error.message.includes('No file coverage')) {
coverageMap.addFileCoverage(coverage);
} else {
throw error;
}
}
}
} catch (error) {
console.warn(`Failed to translate coverage for ${filePath}:`, error);
}
}
}
return coverageMap;
}
/**
* Persist the Istanbul coverage map as JSON and LCOV artefacts for downstream tooling.
*
* @param {import('istanbul-lib-coverage').CoverageMap} coverageMap The populated coverage map.
* @returns {Promise<void>} A promise that resolves when the outputs are written.
*/
async function writeCoverageOutputs(coverageMap) {
const jsonOutputPath = path.join(reportsDir, jsonOutputName);
const lcovOutputPath = path.join(reportsDir, lcovOutputName);
await fs.writeFile(jsonOutputPath, `${JSON.stringify(coverageMap.toJSON(), null, 2)}\n`);
const context = createContext({ dir: reportsDir, coverageMap });
istanbulReports.create('lcovonly', { file: lcovOutputName }).execute(context);
console.log(`Wrote coverage reports to ${jsonOutputPath} and ${lcovOutputPath}`);
}
await ensureReportsDir();
await copyLatestCoverage();
const coverageFiles = await listCoverageFiles();
if (!coverageFiles.length) {
process.exit(0);
}
const coverageMap = await buildCoverageMap(coverageFiles);
if (!coverageMap.files().length) {
console.warn('No project coverage entries were recognised; skipping export.');
process.exit(0);
}
await writeCoverageOutputs(coverageMap);
+470 -24
View File
@@ -33,12 +33,38 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
describe ".resolve_port" do
it "always returns the baked-in default port" do
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ENV["PORT"] = "51515"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ensure
around do |example|
original_port = ENV["PORT"]
begin
example.run
ensure
if original_port
ENV["PORT"] = original_port
else
ENV.delete("PORT")
end
end
end
it "returns the baked-in default port when PORT is not provided" do
ENV.delete("PORT")
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
end
it "honours a valid PORT override" do
ENV["PORT"] = "51515"
expect(application_class.resolve_port).to eq(51_515)
end
it "falls back to the default for invalid PORT values" do
ENV["PORT"] = "abc"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ENV["PORT"] = "70000"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
ENV["PORT"] = "0"
expect(application_class.resolve_port).to eq(PotatoMesh::Application::DEFAULT_PORT)
end
end
@@ -280,6 +306,40 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(result).to be(dummy_thread)
expect(app.settings.federation_thread).to be(dummy_thread)
end
context "when federation is disabled" do
around do |example|
original = ENV["FEDERATION"]
begin
ENV["FEDERATION"] = "0"
example.run
ensure
if original.nil?
ENV.delete("FEDERATION")
else
ENV["FEDERATION"] = original
end
end
end
it "does not start the initial announcement thread" do
expect(Thread).not_to receive(:new)
result = app.start_initial_federation_announcement!
expect(result).to be_nil
expect(app.settings.respond_to?(:initial_federation_thread) ? app.settings.initial_federation_thread : nil).to be_nil
end
it "does not start the recurring announcer thread" do
expect(Thread).not_to receive(:new)
result = app.start_federation_announcer!
expect(result).to be_nil
expect(app.settings.federation_thread).to be_nil
end
end
end
before do
@@ -753,6 +813,52 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe ".self_instance_domain" do
around do |example|
original_app_env = ENV["APP_ENV"]
original_rack_env = ENV["RACK_ENV"]
begin
example.run
ensure
if original_app_env
ENV["APP_ENV"] = original_app_env
else
ENV.delete("APP_ENV")
end
if original_rack_env
ENV["RACK_ENV"] = original_rack_env
else
ENV.delete("RACK_ENV")
end
end
end
it "returns the sanitized domain when configuration is present" do
ENV.delete("APP_ENV")
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ") do
expect(application_class.self_instance_domain).to eq("example.org")
end
end
it "returns nil when the domain is unavailable outside production" do
ENV["APP_ENV"] = "development"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
expect(application_class.self_instance_domain).to be_nil
end
end
it "raises when the domain is unavailable in production" do
ENV["APP_ENV"] = "production"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
expect { application_class.self_instance_domain }.to raise_error(
RuntimeError,
"INSTANCE_DOMAIN could not be determined",
)
end
end
end
describe ".self_instance_registration_decision" do
let(:domain) { "spec.mesh.test" }
@@ -858,6 +964,34 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(targets).to eq(["potatomesh.net"])
end
it "ignores remote instances that have not updated within a week" do
with_db do |db|
db.execute("DELETE FROM instances")
stale_time = (Time.now.to_i - PotatoMesh::Config.week_seconds - 60)
db.execute(
"INSERT INTO instances (id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, signature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
"stale-id",
"stale.mesh",
"pubkey",
"Stale",
"1.0.0",
nil,
nil,
nil,
nil,
stale_time,
0,
"signature",
],
)
end
targets = application_class.federation_target_domains("self.mesh")
expect(targets).to eq(["potatomesh.net"])
end
end
describe ".latest_node_update_timestamp" do
@@ -1000,6 +1134,29 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('class="footer-content"')
end
it "renders the federation instance selector when federation is enabled" do
get "/"
expect(last_response.body).to include('id="instanceSelect"')
expect(last_response.body).to include("Select region ...")
end
it "omits the instance selector when private mode is active" do
allow(PotatoMesh::Config).to receive(:private_mode_enabled?).and_return(true)
get "/"
expect(last_response.body).not_to include('id="instanceSelect"')
end
it "omits the instance selector when federation is disabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
get "/"
expect(last_response.body).not_to include('id="instanceSelect"')
end
it "includes SEO metadata from configuration" do
allow(PotatoMesh::Config).to receive(:site_name).and_return("Spec Mesh Title")
allow(PotatoMesh::Config).to receive(:channel).and_return("#SpecChannel")
@@ -2023,6 +2180,28 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(domains).to eq(["duplicate.example"])
end
end
context "when federation is disabled" do
around do |example|
original = ENV["FEDERATION"]
begin
ENV["FEDERATION"] = "0"
example.run
ensure
if original.nil?
ENV.delete("FEDERATION")
else
ENV["FEDERATION"] = original
end
end
end
it "returns 404" do
get "/api/instances"
expect(last_response.status).to eq(404)
end
end
end
describe "POST /api/nodes" do
@@ -2742,11 +2921,57 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(first["channel_utilization"], payload[0].dig("device_metrics", "channelUtilization"))
expect_same_value(first["air_util_tx"], payload[0].dig("device_metrics", "airUtilTx"))
expect(first["uptime_seconds"]).to eq(payload[0].dig("device_metrics", "uptimeSeconds"))
expect_same_value(first["current"], payload[0]["current"])
expect_same_value(first["gas_resistance"], payload[0]["gas_resistance"])
expect_same_value(first["iaq"], payload[0]["iaq"])
expect_same_value(first["distance"], payload[0]["distance"])
expect_same_value(first["lux"], payload[0]["lux"])
expect_same_value(first["white_lux"], payload[0]["white_lux"])
expect_same_value(first["ir_lux"], payload[0]["ir_lux"])
expect_same_value(first["uv_lux"], payload[0]["uv_lux"])
expect_same_value(first["wind_direction"], payload[0]["wind_direction"])
expect_same_value(first["wind_speed"], payload[0]["wind_speed"])
expect_same_value(first["wind_gust"], payload[0]["wind_gust"])
expect_same_value(first["wind_lull"], payload[0]["wind_lull"])
expect_same_value(first["weight"], payload[0]["weight"])
expect_same_value(first["radiation"], payload[0]["radiation"])
expect_same_value(first["rainfall_1h"], payload[0]["rainfall_1h"])
expect_same_value(first["rainfall_24h"], payload[0]["rainfall_24h"])
expect_same_value(first["soil_moisture"], payload[0]["soil_moisture"])
expect_same_value(first["soil_temperature"], payload[0]["soil_temperature"])
environment_row = rows.find { |row| row["id"] == payload[1]["id"] }
expect(environment_row["temperature"]).to be_within(1e-6).of(payload[1].dig("environment_metrics", "temperature"))
expect(environment_row["relative_humidity"]).to be_within(1e-6).of(payload[1].dig("environment_metrics", "relativeHumidity"))
expect(environment_row["barometric_pressure"]).to be_within(1e-6).of(payload[1].dig("environment_metrics", "barometricPressure"))
expect_same_value(environment_row["gas_resistance"], payload[1].dig("environment_metrics", "gasResistance"))
expect_same_value(environment_row["iaq"], payload[1].dig("environment_metrics", "iaq"))
expect_same_value(environment_row["distance"], payload[1].dig("environment_metrics", "distance"))
expect_same_value(environment_row["lux"], payload[1].dig("environment_metrics", "lux"))
expect_same_value(environment_row["white_lux"], payload[1].dig("environment_metrics", "whiteLux"))
expect_same_value(environment_row["ir_lux"], payload[1].dig("environment_metrics", "irLux"))
expect_same_value(environment_row["uv_lux"], payload[1].dig("environment_metrics", "uvLux"))
expect_same_value(environment_row["wind_direction"], payload[1].dig("environment_metrics", "windDirection"))
expect_same_value(environment_row["wind_speed"], payload[1].dig("environment_metrics", "windSpeed"))
expect_same_value(environment_row["wind_gust"], payload[1].dig("environment_metrics", "windGust"))
expect_same_value(environment_row["wind_lull"], payload[1].dig("environment_metrics", "windLull"))
expect_same_value(environment_row["weight"], payload[1].dig("environment_metrics", "weight"))
expect_same_value(environment_row["radiation"], payload[1].dig("environment_metrics", "radiation"))
expect_same_value(environment_row["rainfall_1h"], payload[1].dig("environment_metrics", "rainfall1h"))
expect_same_value(environment_row["rainfall_24h"], payload[1].dig("environment_metrics", "rainfall24h"))
expect_same_value(environment_row["soil_moisture"], payload[1].dig("environment_metrics", "soilMoisture"))
expect_same_value(environment_row["soil_temperature"], payload[1].dig("environment_metrics", "soilTemperature"))
third_row = rows.find { |row| row["id"] == payload[2]["id"] }
expect_same_value(third_row["current"], payload[2].dig("device_metrics", "current"))
expect_same_value(third_row["distance"], payload[2].dig("environment_metrics", "distance"))
expect_same_value(third_row["lux"], payload[2].dig("environment_metrics", "lux"))
expect_same_value(third_row["wind_direction"], payload[2].dig("environment_metrics", "windDirection"))
expect_same_value(third_row["wind_speed"], payload[2].dig("environment_metrics", "windSpeed"))
expect_same_value(third_row["weight"], payload[2].dig("environment_metrics", "weight"))
expect_same_value(third_row["rainfall_24h"], payload[2].dig("environment_metrics", "rainfall24h"))
expect_same_value(third_row["soil_moisture"], payload[2].dig("environment_metrics", "soilMoisture"))
expect_same_value(third_row["soil_temperature"], payload[2].dig("environment_metrics", "soilTemperature"))
end
with_db(readonly: true) do |db|
@@ -3144,30 +3369,19 @@ RSpec.describe "Potato Mesh Sinatra app" do
expected = expected_node_row(node)
actual_row = actual_by_id.fetch(node["node_id"])
expect(actual_row["short_name"]).to eq(expected["short_name"])
expect(actual_row["long_name"]).to eq(expected["long_name"])
expect(actual_row["hw_model"]).to eq(expected["hw_model"])
expect(actual_row["role"]).to eq(expected["role"])
expect_same_value(actual_row["snr"], expected["snr"])
expect_same_value(actual_row["battery_level"], expected["battery_level"])
expect_same_value(actual_row["voltage"], expected["voltage"])
expect(actual_row["last_heard"]).to eq(expected["last_heard"])
expect(actual_row["first_heard"]).to eq(expected["first_heard"])
expect_same_value(actual_row["uptime_seconds"], expected["uptime_seconds"])
expect_same_value(actual_row["channel_utilization"], expected["channel_utilization"])
expect_same_value(actual_row["air_util_tx"], expected["air_util_tx"])
expect_same_value(actual_row["position_time"], expected["position_time"])
expect(actual_row["location_source"]).to eq(expected["location_source"])
expect_same_value(actual_row["precision_bits"], expected["precision_bits"])
expect_same_value(actual_row["latitude"], expected["latitude"])
expect_same_value(actual_row["longitude"], expected["longitude"])
expect_same_value(actual_row["altitude"], expected["altitude"])
expected.each do |key, value|
if value.nil?
expect(actual_row).not_to have_key(key), "expected #{key} to be omitted"
else
expect_same_value(actual_row[key], value)
end
end
if expected["last_heard"]
expected_last_seen_iso = Time.at(expected["last_heard"]).utc.iso8601
expect(actual_row["last_seen_iso"]).to eq(expected_last_seen_iso)
else
expect(actual_row["last_seen_iso"]).to be_nil
expect(actual_row).not_to have_key("last_seen_iso")
end
if node["position_time"]
@@ -3212,6 +3426,64 @@ RSpec.describe "Potato Mesh Sinatra app" do
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!fresh-node")
end
it "omits blank values from node responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, battery_level, voltage, last_heard, first_heard, uptime_seconds, channel_utilization, air_util_tx, position_time, location_source, precision_bits, latitude, longitude, altitude) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
"!blank",
" ",
nil,
"",
nil,
nil,
nil,
nil,
now,
now,
nil,
nil,
nil,
nil,
" ",
nil,
nil,
nil,
nil,
],
)
end
get "/api/nodes"
expect(last_response).to be_ok
nodes = JSON.parse(last_response.body)
expect(nodes.length).to eq(1)
entry = nodes.first
expect(entry["node_id"]).to eq("!blank")
%w[short_name long_name hw_model snr battery_level voltage uptime_seconds channel_utilization air_util_tx position_time location_source precision_bits latitude longitude altitude].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
expect(entry["role"]).to eq("CLIENT")
expect(entry["last_heard"]).to eq(now)
expect(entry["first_heard"]).to eq(now)
expect(entry["last_seen_iso"]).to eq(Time.at(now).utc.iso8601)
expect(entry).not_to have_key("pos_time_iso")
get "/api/nodes/!blank"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!blank")
expect(payload).not_to have_key("short_name")
expect(payload).not_to have_key("hw_model")
end
end
describe "GET /api/messages" do
@@ -3386,6 +3658,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.status).to eq(404)
end
it "returns 404 for HEAD /api/messages" do
head "/api/messages"
expect(last_response.status).to eq(404)
end
it "returns 404 for POST /api/messages" do
post "/api/messages", {}.to_json, auth_headers
expect(last_response.status).to eq(404)
@@ -3496,6 +3773,55 @@ RSpec.describe "Potato Mesh Sinatra app" do
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
it "omits blank values from position responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude, altitude, location_source, precision_bits, sats_in_view, pdop, payload_b64) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
7,
"!pos-blank",
nil,
now,
" ",
nil,
nil,
nil,
nil,
" ",
nil,
nil,
nil,
"",
],
)
end
get "/api/positions"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!pos-blank")
expect(entry["rx_time"]).to eq(now)
expect(entry["rx_iso"]).to eq(Time.at(now).utc.iso8601)
%w[position_time latitude longitude altitude location_source precision_bits sats_in_view pdop payload_b64].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
get "/api/positions/!pos-blank"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("payload_b64")
expect(filtered.first).not_to have_key("location_source")
end
end
describe "GET /api/neighbors" do
@@ -3545,6 +3871,45 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
expect(filtered.first["rx_time"]).to eq(fresh_rx)
end
it "omits blank values from neighbor responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!origin", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor", "neig", "Neighbor", "TBEAM", "CLIENT", 0.0, now, now],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!origin", "!neighbor", nil, now],
)
end
get "/api/neighbors"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.length).to eq(1)
entry = payload.first
expect(entry["node_id"]).to eq("!origin")
expect(entry["neighbor_id"]).to eq("!neighbor")
expect(entry["rx_time"]).to eq(now)
expect(entry).not_to have_key("snr")
get "/api/neighbors/!origin"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("snr")
end
end
describe "GET /api/telemetry" do
@@ -3569,6 +3934,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(first_entry["telemetry_time_iso"]).to eq(Time.at(latest["telemetry_time"]).utc.iso8601)
expect(first_entry).not_to have_key("device_metrics")
expect_same_value(first_entry["battery_level"], latest.dig("device_metrics", "battery_level") || latest.dig("device_metrics", "batteryLevel"))
expect_same_value(first_entry["current"], latest.dig("device_metrics", "current"))
expect_same_value(first_entry["distance"], latest.dig("environment_metrics", "distance"))
expect_same_value(first_entry["lux"], latest.dig("environment_metrics", "lux"))
expect_same_value(first_entry["wind_direction"], latest.dig("environment_metrics", "windDirection"))
expect_same_value(first_entry["wind_speed"], latest.dig("environment_metrics", "windSpeed"))
expect_same_value(first_entry["weight"], latest.dig("environment_metrics", "weight"))
expect_same_value(first_entry["rainfall_24h"], latest.dig("environment_metrics", "rainfall24h"))
expect_same_value(first_entry["soil_moisture"], latest.dig("environment_metrics", "soilMoisture"))
expect_same_value(first_entry["soil_temperature"], latest.dig("environment_metrics", "soilTemperature"))
second_entry = data.last
expect(second_entry["id"]).to eq(second_latest["id"])
@@ -3576,6 +3950,23 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(second_entry["temperature"]).to be_within(1e-6).of(second_latest["environment_metrics"]["temperature"])
expect(second_entry["relative_humidity"]).to be_within(1e-6).of(second_latest["environment_metrics"]["relativeHumidity"])
expect(second_entry["barometric_pressure"]).to be_within(1e-6).of(second_latest["environment_metrics"]["barometricPressure"])
expect_same_value(second_entry["gas_resistance"], second_latest.dig("environment_metrics", "gasResistance"))
expect_same_value(second_entry["iaq"], second_latest.dig("environment_metrics", "iaq"))
expect_same_value(second_entry["distance"], second_latest.dig("environment_metrics", "distance"))
expect_same_value(second_entry["lux"], second_latest.dig("environment_metrics", "lux"))
expect_same_value(second_entry["white_lux"], second_latest.dig("environment_metrics", "whiteLux"))
expect_same_value(second_entry["ir_lux"], second_latest.dig("environment_metrics", "irLux"))
expect_same_value(second_entry["uv_lux"], second_latest.dig("environment_metrics", "uvLux"))
expect_same_value(second_entry["wind_direction"], second_latest.dig("environment_metrics", "windDirection"))
expect_same_value(second_entry["wind_speed"], second_latest.dig("environment_metrics", "windSpeed"))
expect_same_value(second_entry["wind_gust"], second_latest.dig("environment_metrics", "windGust"))
expect_same_value(second_entry["wind_lull"], second_latest.dig("environment_metrics", "windLull"))
expect_same_value(second_entry["weight"], second_latest.dig("environment_metrics", "weight"))
expect_same_value(second_entry["radiation"], second_latest.dig("environment_metrics", "radiation"))
expect_same_value(second_entry["rainfall_1h"], second_latest.dig("environment_metrics", "rainfall1h"))
expect_same_value(second_entry["rainfall_24h"], second_latest.dig("environment_metrics", "rainfall24h"))
expect_same_value(second_entry["soil_moisture"], second_latest.dig("environment_metrics", "soilMoisture"))
expect_same_value(second_entry["soil_temperature"], second_latest.dig("environment_metrics", "soilTemperature"))
end
it "excludes telemetry entries older than seven days" do
@@ -3609,5 +4000,60 @@ RSpec.describe "Potato Mesh Sinatra app" do
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
it "omits blank values from telemetry responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, channel, portnum, hop_limit, snr, rssi, bitfield, payload_b64, battery_level, voltage, channel_utilization, air_util_tx, uptime_seconds, temperature, relative_humidity) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
[
77,
"!tele-blank",
nil,
now,
" ",
nil,
nil,
"",
nil,
nil,
nil,
nil,
"",
nil,
nil,
nil,
nil,
nil,
nil,
nil,
],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!tele-blank")
expect(entry["rx_time"]).to eq(now)
expect(entry["rx_iso"]).to eq(Time.at(now).utc.iso8601)
%w[telemetry_time channel portnum hop_limit snr rssi bitfield payload_b64 battery_level voltage channel_utilization air_util_tx uptime_seconds temperature relative_humidity].each do |attribute|
expect(entry).not_to have_key(attribute), "expected #{attribute} to be omitted"
end
get "/api/telemetry/!tele-blank"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first).not_to have_key("battery_level")
expect(filtered.first).not_to have_key("portnum")
end
end
end
+78 -10
View File
@@ -115,6 +115,58 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".private_mode_enabled?" do
it "returns false when PRIVATE is unset" do
within_env("PRIVATE" => nil) do
expect(described_class.private_mode_enabled?).to be(false)
end
end
it "returns false when PRIVATE=0" do
within_env("PRIVATE" => "0") do
expect(described_class.private_mode_enabled?).to be(false)
end
end
it "returns true when PRIVATE=1" do
within_env("PRIVATE" => "1") do
expect(described_class.private_mode_enabled?).to be(true)
end
end
it "ignores surrounding whitespace" do
within_env("PRIVATE" => " 1 ") do
expect(described_class.private_mode_enabled?).to be(true)
end
end
end
describe ".federation_enabled?" do
it "returns true when FEDERATION is unset" do
within_env("FEDERATION" => nil, "PRIVATE" => "0") do
expect(described_class.federation_enabled?).to be(true)
end
end
it "returns false when FEDERATION=0" do
within_env("FEDERATION" => "0", "PRIVATE" => "0") do
expect(described_class.federation_enabled?).to be(false)
end
end
it "returns false when PRIVATE=1" do
within_env("FEDERATION" => "1", "PRIVATE" => "1") do
expect(described_class.federation_enabled?).to be(false)
end
end
it "ignores surrounding whitespace" do
within_env("FEDERATION" => " 0 ", "PRIVATE" => "0") do
expect(described_class.federation_enabled?).to be(false)
end
end
end
describe ".legacy_well_known_candidates" do
it "includes repository config directories" do
Dir.mktmpdir do |dir|
@@ -138,14 +190,22 @@ RSpec.describe PotatoMesh::Config do
end
describe ".remote_instance_http_timeout" do
it "returns the baked-in connect timeout" do
expect(described_class.remote_instance_http_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
it "returns the baked-in connect timeout when unset" do
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => nil) do
expect(described_class.remote_instance_http_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
end
end
it "ignores environment overrides" do
it "accepts positive environment overrides" do
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => "27") do
expect(described_class.remote_instance_http_timeout).to eq(27)
end
end
it "rejects non-positive overrides" do
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => "0") do
expect(described_class.remote_instance_http_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
@@ -154,14 +214,22 @@ RSpec.describe PotatoMesh::Config do
end
describe ".remote_instance_read_timeout" do
it "returns the baked-in read timeout" do
expect(described_class.remote_instance_read_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
it "returns the baked-in read timeout when unset" do
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => nil) do
expect(described_class.remote_instance_read_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
end
end
it "ignores environment overrides" do
it "accepts positive overrides" do
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => "20") do
expect(described_class.remote_instance_read_timeout).to eq(20)
end
end
it "rejects non-positive overrides" do
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => "-5") do
expect(described_class.remote_instance_read_timeout).to eq(
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
+166
View File
@@ -0,0 +1,166 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "sqlite3"
RSpec.describe PotatoMesh::App::Database do
let(:harness_class) do
Class.new do
extend PotatoMesh::App::Database
extend PotatoMesh::App::Helpers
class << self
attr_reader :warnings
# Capture warning log entries generated during migrations for
# inspection within the unit tests.
#
# @param message [String] warning message text.
# @param context [String] logical source of the log entry.
# @param metadata [Hash] structured metadata supplied by the caller.
# @return [void]
def warn_log(message, context:, **metadata)
@warnings ||= []
@warnings << { message: message, context: context, metadata: metadata }
end
# Capture debug log entries generated during migrations for
# completeness of the helper interface.
#
# @param message [String] debug message text.
# @param context [String] logical source of the log entry.
# @param metadata [Hash] structured metadata supplied by the caller.
# @return [void]
def debug_log(message, context:, **metadata)
@debug_entries ||= []
@debug_entries << { message: message, context: context, metadata: metadata }
end
# Reset captured log entries between test examples.
#
# @return [void]
def reset_logs!
@warnings = []
@debug_entries = []
end
end
end
end
around do |example|
harness_class.reset_logs!
Dir.mktmpdir("db-upgrade-spec-") do |dir|
db_path = File.join(dir, "mesh.db")
RSpec::Mocks.with_temporary_scope do
allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:default_db_path).and_return(db_path)
allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(db_path)
example.run
end
end
ensure
harness_class.reset_logs!
end
# Retrieve column names for the requested table within the temporary
# database used for upgrade tests.
#
# @param table [String] table name whose columns should be returned.
# @return [Array<String>] names of the columns defined on +table+.
def column_names_for(table)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true)
db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
ensure
db&.close
end
it "adds missing telemetry columns when upgrading an existing schema" do
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
db.execute("CREATE TABLE nodes(node_id TEXT)")
db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
db.execute <<~SQL
CREATE TABLE telemetry (
id INTEGER PRIMARY KEY,
node_id TEXT,
node_num INTEGER,
from_id TEXT,
to_id TEXT,
rx_time INTEGER NOT NULL,
rx_iso TEXT NOT NULL,
telemetry_time INTEGER,
channel INTEGER,
portnum TEXT,
hop_limit INTEGER,
snr REAL,
rssi INTEGER,
bitfield INTEGER,
payload_b64 TEXT,
battery_level REAL,
voltage REAL,
channel_utilization REAL,
air_util_tx REAL,
uptime_seconds INTEGER,
temperature REAL,
relative_humidity REAL,
barometric_pressure REAL
)
SQL
end
harness_class.ensure_schema_upgrades
telemetry_columns = column_names_for("telemetry")
expect(telemetry_columns).to include(
"gas_resistance",
"current",
"iaq",
"distance",
"lux",
"white_lux",
"ir_lux",
"uv_lux",
"wind_direction",
"wind_speed",
"weight",
"wind_gust",
"wind_lull",
"radiation",
"rainfall_1h",
"rainfall_24h",
"soil_moisture",
"soil_temperature",
)
expect { harness_class.ensure_schema_upgrades }.not_to raise_error
end
it "initialises the telemetry table when it is missing" do
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
db.execute("CREATE TABLE nodes(node_id TEXT)")
db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
end
expect(column_names_for("telemetry")).to be_empty
harness_class.ensure_schema_upgrades
telemetry_columns = column_names_for("telemetry")
expect(telemetry_columns).to include("soil_temperature", "lux", "iaq")
expect(telemetry_columns).to include("rx_time", "battery_level")
end
end
+76
View File
@@ -298,6 +298,38 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe ".federation_user_agent_header" do
it "combines the version and sanitized domain" do
allow(federation_helpers).to receive(:app_constant).and_call_original
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("9.9.9")
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
header = federation_helpers.federation_user_agent_header
expect(header).to eq("PotatoMesh/9.9.9 (+https://example.mesh)")
end
it "falls back to the product name when the domain is unavailable" do
allow(federation_helpers).to receive(:app_constant).and_call_original
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("1.2.3")
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return(nil)
header = federation_helpers.federation_user_agent_header
expect(header).to eq("PotatoMesh/1.2.3")
end
it "uses an explicit unknown marker when the version is blank" do
allow(federation_helpers).to receive(:app_constant).and_call_original
allow(federation_helpers).to receive(:app_constant).with(:APP_VERSION).and_return("")
allow(federation_helpers).to receive(:app_constant).with(:INSTANCE_DOMAIN).and_return("Example.Mesh")
header = federation_helpers.federation_user_agent_header
expect(header).to eq("PotatoMesh/unknown (+https://example.mesh)")
end
end
describe ".perform_instance_http_request" do
let(:uri) { URI.parse("https://remote.example.com/api") }
let(:http_client) { instance_double(Net::HTTP) }
@@ -350,6 +382,30 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.send(:perform_instance_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "ArgumentError: restricted domain")
end
it "applies federation headers to instance fetch requests" do
connection = instance_double("Net::HTTPConnection")
success_response = Net::HTTPOK.new("1.1", "200", "OK")
allow(success_response).to receive(:body).and_return("{}")
allow(success_response).to receive(:code).and_return("200")
captured_request = nil
allow(http_client).to receive(:start) do |&block|
block.call(connection)
end
allow(connection).to receive(:request) do |request|
captured_request = request
success_response
end
result = federation_helpers.send(:perform_instance_http_request, uri)
expect(result).to eq("{}")
expect(captured_request).not_to be_nil
expect(captured_request["Accept"]).to eq("application/json")
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
expect(captured_request["Content-Type"]).to be_nil
end
end
describe ".announce_instance_to_domain" do
@@ -399,5 +455,25 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.warn_messages.count { |message| message.include?("Federation announcement raised exception") },
).to eq(2)
end
it "applies federation headers to announcement requests" do
https_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
captured_request = nil
allow(https_client).to receive(:start).and_yield(http_connection).and_return(success_response)
allow(http_connection).to receive(:request) do |request|
captured_request = request
success_response
end
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
expect(result).to be(true)
expect(captured_request).not_to be_nil
expect(captured_request["Content-Type"]).to eq("application/json")
expect(captured_request["Accept"]).to eq("application/json")
expect(captured_request["User-Agent"]).to eq(federation_helpers.send(:federation_user_agent_header))
end
end
end
+99
View File
@@ -0,0 +1,99 @@
# Copyright (C) 2025 l5yth
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "sqlite3"
RSpec.describe PotatoMesh::App::Instances do
let(:application_class) { PotatoMesh::Application }
let(:week_seconds) { PotatoMesh::Config.week_seconds }
# Execute the provided block with a configured SQLite connection.
#
# @param readonly [Boolean] whether the connection should be read-only.
# @yieldparam db [SQLite3::Database] configured database handle.
# @return [void]
def with_db(readonly: false)
db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly)
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db.execute("PRAGMA foreign_keys = ON")
yield db
ensure
db&.close
end
before do
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
application_class.init_db unless application_class.db_schema_present?
with_db do |db|
db.execute("DELETE FROM instances")
end
end
describe ".load_instances_for_api" do
it "only returns instances updated within the configured rolling window" do
fixed_time = Time.utc(2025, 1, 15, 12, 0, 0)
allow(Time).to receive(:now).and_return(fixed_time)
application_class.ensure_self_instance_record!
recent_timestamp = fixed_time.to_i - (week_seconds / 2)
stale_timestamp = fixed_time.to_i - week_seconds - 60
with_db do |db|
db.execute(
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private) VALUES (?, ?, ?, ?, ?)",
[
"recent-instance",
"recent.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
recent_timestamp,
0,
],
)
db.execute(
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private) VALUES (?, ?, ?, ?, ?)",
[
"stale-instance",
"stale.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
stale_timestamp,
0,
],
)
db.execute(
"INSERT INTO instances (id, domain, pubkey, is_private) VALUES (?, ?, ?, ?)",
[
"missing-instance",
"missing.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
0,
],
)
end
payload = application_class.load_instances_for_api
domains = payload.map { |row| row["domain"] }
lower_bound = fixed_time.to_i - week_seconds
expect(domains).to include("recent.mesh.test")
expect(domains).to include(application_class.app_constant(:INSTANCE_DOMAIN))
expect(domains).not_to include("stale.mesh.test")
expect(domains).not_to include("missing.mesh.test")
expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true)
end
end
end
+23 -16
View File
@@ -66,20 +66,30 @@
crossorigin=""
></script>
</head>
<% body_classes = [] %>
<% body_classes << "dark" if initial_theme == "dark" %>
<body class="<%= body_classes.join(" ") %>" data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>" data-theme="<%= initial_theme %>">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<div class="site-header">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
<% end %>
</div>
<div class="row meta">
<div class="meta-info">
<div class="refresh-row">
@@ -97,8 +107,8 @@
<input type="text" id="filterInput" placeholder="Filter nodes" />
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<button id="themeToggle" type="button" aria-label="Toggle dark mode">🌙</button>
<button id="infoBtn" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information">️ Info</button>
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true">️</span></button>
</div>
</div>
@@ -106,7 +116,6 @@
<div class="info-dialog" tabindex="-1">
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
<p class="info-intro">Quick facts about this PotatoMesh instance.</p>
<dl class="info-details">
<dt>Channel</dt>
<dd><%= channel %></dd>
@@ -116,8 +125,6 @@
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
<dt>Visible range</dt>
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
<dt>Auto-refresh</dt>
<dd>Updates every <%= refresh_interval_seconds %> seconds.</dd>
<% if contact_link && !contact_link.empty? %>
<dt>Chat</dt>
<% if contact_link_url %>
@@ -221,7 +228,7 @@
</div>
</footer>
<script>
const CHAT_ENABLED = <%= private_mode ? "false" : "true" %>;