mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-09 14:55:08 +02:00
Compare commits
27 Commits
v0.5.2-rc2
...
v0.5.4-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
| e27d5ab53c | |||
| 6af272c01f | |||
| 03e2fe6a72 | |||
| 87b4cd79e7 | |||
| d94d75e605 | |||
| c965d05229 | |||
| ba80fac36c | |||
| 3c2c7611ee | |||
| 49e0f39ca9 | |||
| 625df7982d | |||
| 8eeb13166b | |||
| 80645990cb | |||
| 96a3bb86e9 | |||
| 6775de3cca | |||
| 8143fbd8f7 | |||
| cf3949ef95 | |||
| 32d9da2865 | |||
| 61e8c92f62 | |||
| d954df6294 | |||
| 30d535bd43 | |||
| d06aa42ab2 | |||
| 108fc93ca1 | |||
| 427479c1e6 | |||
| ee05f312e8 | |||
| c4193e38dc | |||
| cb9b081606 | |||
| cc8fec6d05 |
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+162
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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)} MΩ`;
|
||||
}
|
||||
if (absVal >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(2)} kΩ`;
|
||||
}
|
||||
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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>×</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" %>;
|
||||
|
||||
Reference in New Issue
Block a user