mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-18 02:56:21 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1c0b4849 | ||
|
|
b47571c7f0 | ||
|
|
5bb9bc146e | ||
|
|
12d1174e39 | ||
|
|
3bbd986d41 | ||
|
|
712fa15a8c | ||
|
|
ab03b142f5 | ||
|
|
def95aae64 | ||
|
|
1b09c733f5 | ||
|
|
553c0e4963 | ||
|
|
8ede8427c8 | ||
|
|
8e66c68d6f | ||
|
|
37396823ad |
@@ -10,11 +10,6 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- 'LICENSE'
|
|
||||||
- '.gitignore'
|
|
||||||
- 'docs/**'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -41,10 +36,25 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Skip if docs-only change
|
||||||
|
id: docs-check
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
|
||||||
|
NON_DOCS=$(echo "$CHANGED" | grep -cvE '\.(md)$|^LICENSE$|^\.gitignore$|^docs/' || true)
|
||||||
|
if [ "$NON_DOCS" -eq 0 ]; then
|
||||||
|
echo "docs_only=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "📄 Docs-only PR — skipping heavy CI"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Go 1.22
|
- name: Set up Go 1.22
|
||||||
uses: actions/setup-go@v5
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: '1.22'
|
go-version: '1.22'
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
@@ -52,6 +62,7 @@ jobs:
|
|||||||
cmd/ingestor/go.sum
|
cmd/ingestor/go.sum
|
||||||
|
|
||||||
- name: Build and test Go server (with coverage)
|
- name: Build and test Go server (with coverage)
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
cd cmd/server
|
cd cmd/server
|
||||||
@@ -61,6 +72,7 @@ jobs:
|
|||||||
go tool cover -func=server-coverage.out | tail -1
|
go tool cover -func=server-coverage.out | tail -1
|
||||||
|
|
||||||
- name: Build and test Go ingestor (with coverage)
|
- name: Build and test Go ingestor (with coverage)
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e -o pipefail
|
set -e -o pipefail
|
||||||
cd cmd/ingestor
|
cd cmd/ingestor
|
||||||
@@ -70,6 +82,7 @@ jobs:
|
|||||||
go tool cover -func=ingestor-coverage.out | tail -1
|
go tool cover -func=ingestor-coverage.out | tail -1
|
||||||
|
|
||||||
- name: Verify proto syntax (all .proto files compile)
|
- name: Verify proto syntax (all .proto files compile)
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
echo "Installing protoc..."
|
echo "Installing protoc..."
|
||||||
@@ -84,7 +97,7 @@ jobs:
|
|||||||
echo "✅ All .proto files are syntactically valid"
|
echo "✅ All .proto files are syntactically valid"
|
||||||
|
|
||||||
- name: Generate Go coverage badges
|
- name: Generate Go coverage badges
|
||||||
if: always()
|
if: always() && steps.docs-check.outputs.docs_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
mkdir -p .badges
|
mkdir -p .badges
|
||||||
|
|
||||||
@@ -131,7 +144,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Go coverage badges
|
- name: Upload Go coverage badges
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: go-badges
|
name: go-badges
|
||||||
path: .badges/go-*.json
|
path: .badges/go-*.json
|
||||||
@@ -143,22 +156,40 @@ jobs:
|
|||||||
# ───────────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────────
|
||||||
node-test:
|
node-test:
|
||||||
name: "🧪 Node.js Tests"
|
name: "🧪 Node.js Tests"
|
||||||
runs-on: self-hosted
|
runs-on: [self-hosted, Linux]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Skip if docs-only change
|
||||||
|
id: docs-check
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
|
||||||
|
NON_DOCS=$(echo "$CHANGED" | grep -cvE '\.(md)$|^LICENSE$|^\.gitignore$|^docs/' || true)
|
||||||
|
if [ "$NON_DOCS" -eq 0 ]; then
|
||||||
|
echo "docs_only=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "📄 Docs-only PR — skipping heavy CI"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Node.js 22
|
- name: Set up Node.js 22
|
||||||
uses: actions/setup-node@v4
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install npm dependencies
|
- name: Install npm dependencies
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
run: npm ci --production=false
|
run: npm ci --production=false
|
||||||
|
|
||||||
- name: Detect changed files
|
- name: Detect changed files
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
id: changes
|
id: changes
|
||||||
run: |
|
run: |
|
||||||
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
|
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
|
||||||
@@ -174,7 +205,7 @@ jobs:
|
|||||||
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
|
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
|
||||||
|
|
||||||
- name: Run backend tests with coverage
|
- name: Run backend tests with coverage
|
||||||
if: steps.changes.outputs.backend == 'true'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.backend == 'true'
|
||||||
run: |
|
run: |
|
||||||
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
|
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
|
||||||
|
|
||||||
@@ -192,19 +223,23 @@ jobs:
|
|||||||
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
- name: Run backend tests (quick, no coverage)
|
- name: Run backend tests (quick, no coverage)
|
||||||
if: steps.changes.outputs.backend == 'false'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.backend == 'false'
|
||||||
run: npm run test:unit
|
run: npm run test:unit
|
||||||
|
|
||||||
- name: Install Playwright browser
|
- name: Install Playwright browser
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
run: npx playwright install chromium --with-deps 2>/dev/null || true
|
run: |
|
||||||
|
# Install chromium (skips download if already cached on self-hosted runner)
|
||||||
|
npx playwright install chromium 2>/dev/null || true
|
||||||
|
# Install system deps only if missing (apt-get is slow)
|
||||||
|
npx playwright install-deps chromium 2>/dev/null || true
|
||||||
|
|
||||||
- name: Instrument frontend JS for coverage
|
- name: Instrument frontend JS for coverage
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
run: sh scripts/instrument-frontend.sh
|
run: sh scripts/instrument-frontend.sh
|
||||||
|
|
||||||
- name: Start instrumented test server on port 13581
|
- name: Start instrumented test server on port 13581
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
run: |
|
run: |
|
||||||
# Kill any stale server on 13581
|
# Kill any stale server on 13581
|
||||||
fuser -k 13581/tcp 2>/dev/null || true
|
fuser -k 13581/tcp 2>/dev/null || true
|
||||||
@@ -227,19 +262,36 @@ jobs:
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Run Playwright E2E tests
|
- name: Seed test data for Playwright
|
||||||
if: steps.changes.outputs.frontend == 'true'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
run: BASE_URL=http://localhost:13581 node tools/seed-test-data.js
|
||||||
|
|
||||||
- name: Collect frontend coverage report
|
- name: Run Playwright E2E + coverage collection concurrently
|
||||||
if: always() && steps.changes.outputs.frontend == 'true'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
run: |
|
run: |
|
||||||
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
|
# Run E2E tests and coverage collection in parallel — both use the same server
|
||||||
|
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt &
|
||||||
|
E2E_PID=$!
|
||||||
|
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt &
|
||||||
|
COV_PID=$!
|
||||||
|
|
||||||
|
# Wait for both — E2E must pass, coverage is best-effort
|
||||||
|
E2E_EXIT=0
|
||||||
|
wait $E2E_PID || E2E_EXIT=$?
|
||||||
|
wait $COV_PID || true
|
||||||
|
|
||||||
|
# Fail if E2E failed
|
||||||
|
[ $E2E_EXIT -ne 0 ] && exit $E2E_EXIT
|
||||||
|
true
|
||||||
|
|
||||||
|
- name: Generate frontend coverage badges
|
||||||
|
if: always() && steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
|
run: |
|
||||||
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
||||||
|
|
||||||
mkdir -p .badges
|
mkdir -p .badges
|
||||||
if [ -f .nyc_output/frontend-coverage.json ]; then
|
# Merge E2E + coverage collector data if both exist
|
||||||
|
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
|
||||||
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
|
||||||
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
|
||||||
FE_COVERAGE=${FE_COVERAGE:-0}
|
FE_COVERAGE=${FE_COVERAGE:-0}
|
||||||
@@ -252,7 +304,7 @@ jobs:
|
|||||||
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
|
echo "{\"schemaVersion\":1,\"label\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
|
||||||
|
|
||||||
- name: Stop test server
|
- name: Stop test server
|
||||||
if: always() && steps.changes.outputs.frontend == 'true'
|
if: always() && steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'true'
|
||||||
run: |
|
run: |
|
||||||
if [ -f .server.pid ]; then
|
if [ -f .server.pid ]; then
|
||||||
kill $(cat .server.pid) 2>/dev/null || true
|
kill $(cat .server.pid) 2>/dev/null || true
|
||||||
@@ -261,12 +313,17 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run frontend E2E (quick, no coverage)
|
- name: Run frontend E2E (quick, no coverage)
|
||||||
if: steps.changes.outputs.frontend == 'false'
|
if: steps.docs-check.outputs.docs_only != 'true' && steps.changes.outputs.frontend == 'false'
|
||||||
run: |
|
run: |
|
||||||
fuser -k 13581/tcp 2>/dev/null || true
|
fuser -k 13581/tcp 2>/dev/null || true
|
||||||
PORT=13581 node server.js &
|
PORT=13581 node server.js &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
sleep 5
|
# Wait for server to be ready (up to 15s)
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
BASE_URL=http://localhost:13581 node tools/seed-test-data.js || true
|
||||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
||||||
kill $SERVER_PID 2>/dev/null || true
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
|
||||||
@@ -279,7 +336,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Node.js test badges
|
- name: Upload Node.js test badges
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: node-badges
|
name: node-badges
|
||||||
path: .badges/
|
path: .badges/
|
||||||
@@ -293,13 +350,13 @@ jobs:
|
|||||||
name: "🏗️ Build Docker Image"
|
name: "🏗️ Build Docker Image"
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
needs: [go-test, node-test]
|
needs: [go-test, node-test]
|
||||||
runs-on: self-hosted
|
runs-on: [self-hosted, Linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Node.js 22
|
- name: Set up Node.js 22
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
@@ -318,10 +375,10 @@ jobs:
|
|||||||
name: "🚀 Deploy Staging"
|
name: "🚀 Deploy Staging"
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
needs: [build]
|
needs: [build]
|
||||||
runs-on: self-hosted
|
runs-on: [self-hosted, Linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Start staging on port 82
|
- name: Start staging on port 82
|
||||||
run: |
|
run: |
|
||||||
@@ -363,21 +420,21 @@ jobs:
|
|||||||
name: "📝 Publish Badges & Summary"
|
name: "📝 Publish Badges & Summary"
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
needs: [deploy]
|
needs: [deploy]
|
||||||
runs-on: self-hosted
|
runs-on: [self-hosted, Linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download Go coverage badges
|
- name: Download Go coverage badges
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: go-badges
|
name: go-badges
|
||||||
path: .badges/
|
path: .badges/
|
||||||
|
|
||||||
- name: Download Node.js test badges
|
- name: Download Node.js test badges
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: node-badges
|
name: node-badges
|
||||||
path: .badges/
|
path: .badges/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.
|
> High-performance mesh network analyzer powered by Go. Sub-millisecond packet queries, ~300 MB memory for 56K+ packets, real-time WebSocket broadcast, full channel decryption.
|
||||||
|
|
||||||
Self-hosted, open-source MeshCore packet analyzer — a community alternative to the closed-source `analyzer.letsmesh.net`. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
Self-hosted, open-source MeshCore packet analyzer. Collects MeshCore packets via MQTT, decodes them in real time, and presents a full web UI with live packet feed, interactive maps, channel chat, packet tracing, and per-node analytics.
|
||||||
|
|
||||||
## ⚡ Performance
|
## ⚡ Performance
|
||||||
|
|
||||||
|
|||||||
+61
-22
@@ -72,8 +72,8 @@ type Header struct {
|
|||||||
|
|
||||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||||
type TransportCodes struct {
|
type TransportCodes struct {
|
||||||
NextHop string `json:"nextHop"`
|
Code1 string `json:"code1"`
|
||||||
LastHop string `json:"lastHop"`
|
Code2 string `json:"code2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path holds decoded path/hop information.
|
// Path holds decoded path/hop information.
|
||||||
@@ -92,6 +92,8 @@ type AdvertFlags struct {
|
|||||||
Room bool `json:"room"`
|
Room bool `json:"room"`
|
||||||
Sensor bool `json:"sensor"`
|
Sensor bool `json:"sensor"`
|
||||||
HasLocation bool `json:"hasLocation"`
|
HasLocation bool `json:"hasLocation"`
|
||||||
|
HasFeat1 bool `json:"hasFeat1"`
|
||||||
|
HasFeat2 bool `json:"hasFeat2"`
|
||||||
HasName bool `json:"hasName"`
|
HasName bool `json:"hasName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,8 @@ type Payload struct {
|
|||||||
Lat *float64 `json:"lat,omitempty"`
|
Lat *float64 `json:"lat,omitempty"`
|
||||||
Lon *float64 `json:"lon,omitempty"`
|
Lon *float64 `json:"lon,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
|
Feat1 *int `json:"feat1,omitempty"`
|
||||||
|
Feat2 *int `json:"feat2,omitempty"`
|
||||||
BatteryMv *int `json:"battery_mv,omitempty"`
|
BatteryMv *int `json:"battery_mv,omitempty"`
|
||||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
ChannelHash int `json:"channelHash,omitempty"`
|
ChannelHash int `json:"channelHash,omitempty"`
|
||||||
@@ -123,6 +127,8 @@ type Payload struct {
|
|||||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||||
PathData string `json:"pathData,omitempty"`
|
PathData string `json:"pathData,omitempty"`
|
||||||
Tag uint32 `json:"tag,omitempty"`
|
Tag uint32 `json:"tag,omitempty"`
|
||||||
|
AuthCode uint32 `json:"authCode,omitempty"`
|
||||||
|
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||||
RawHex string `json:"raw,omitempty"`
|
RawHex string `json:"raw,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -199,14 +205,13 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeAck(buf []byte) Payload {
|
func decodeAck(buf []byte) Payload {
|
||||||
if len(buf) < 6 {
|
if len(buf) < 4 {
|
||||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||||
}
|
}
|
||||||
|
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||||
return Payload{
|
return Payload{
|
||||||
Type: "ACK",
|
Type: "ACK",
|
||||||
DestHash: hex.EncodeToString(buf[0:1]),
|
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
|
||||||
ExtraHash: hex.EncodeToString(buf[2:6]),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +236,8 @@ func decodeAdvert(buf []byte) Payload {
|
|||||||
if len(appdata) > 0 {
|
if len(appdata) > 0 {
|
||||||
flags := appdata[0]
|
flags := appdata[0]
|
||||||
advType := int(flags & 0x0F)
|
advType := int(flags & 0x0F)
|
||||||
|
hasFeat1 := flags&0x20 != 0
|
||||||
|
hasFeat2 := flags&0x40 != 0
|
||||||
p.Flags = &AdvertFlags{
|
p.Flags = &AdvertFlags{
|
||||||
Raw: int(flags),
|
Raw: int(flags),
|
||||||
Type: advType,
|
Type: advType,
|
||||||
@@ -239,6 +246,8 @@ func decodeAdvert(buf []byte) Payload {
|
|||||||
Room: advType == 3,
|
Room: advType == 3,
|
||||||
Sensor: advType == 4,
|
Sensor: advType == 4,
|
||||||
HasLocation: flags&0x10 != 0,
|
HasLocation: flags&0x10 != 0,
|
||||||
|
HasFeat1: hasFeat1,
|
||||||
|
HasFeat2: hasFeat2,
|
||||||
HasName: flags&0x80 != 0,
|
HasName: flags&0x80 != 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +261,16 @@ func decodeAdvert(buf []byte) Payload {
|
|||||||
p.Lon = &lon
|
p.Lon = &lon
|
||||||
off += 8
|
off += 8
|
||||||
}
|
}
|
||||||
|
if hasFeat1 && len(appdata) >= off+2 {
|
||||||
|
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||||
|
p.Feat1 = &feat1
|
||||||
|
off += 2
|
||||||
|
}
|
||||||
|
if hasFeat2 && len(appdata) >= off+2 {
|
||||||
|
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
|
||||||
|
p.Feat2 = &feat2
|
||||||
|
off += 2
|
||||||
|
}
|
||||||
if p.Flags.HasName {
|
if p.Flags.HasName {
|
||||||
// Find null terminator to separate name from trailing telemetry bytes
|
// Find null terminator to separate name from trailing telemetry bytes
|
||||||
nameEnd := len(appdata)
|
nameEnd := len(appdata)
|
||||||
@@ -469,15 +488,22 @@ func decodePathPayload(buf []byte) Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeTrace(buf []byte) Payload {
|
func decodeTrace(buf []byte) Payload {
|
||||||
if len(buf) < 12 {
|
if len(buf) < 9 {
|
||||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||||
}
|
}
|
||||||
return Payload{
|
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||||
Type: "TRACE",
|
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||||
DestHash: hex.EncodeToString(buf[5:11]),
|
flags := int(buf[8])
|
||||||
SrcHash: hex.EncodeToString(buf[11:12]),
|
p := Payload{
|
||||||
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
Type: "TRACE",
|
||||||
|
Tag: tag,
|
||||||
|
AuthCode: authCode,
|
||||||
|
TraceFlags: &flags,
|
||||||
}
|
}
|
||||||
|
if len(buf) > 9 {
|
||||||
|
p.PathData = hex.EncodeToString(buf[9:])
|
||||||
|
}
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
||||||
@@ -520,8 +546,7 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
|||||||
}
|
}
|
||||||
|
|
||||||
header := decodeHeader(buf[0])
|
header := decodeHeader(buf[0])
|
||||||
pathByte := buf[1]
|
offset := 1
|
||||||
offset := 2
|
|
||||||
|
|
||||||
var tc *TransportCodes
|
var tc *TransportCodes
|
||||||
if isTransportRoute(header.RouteType) {
|
if isTransportRoute(header.RouteType) {
|
||||||
@@ -529,12 +554,18 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
|||||||
return nil, fmt.Errorf("packet too short for transport codes")
|
return nil, fmt.Errorf("packet too short for transport codes")
|
||||||
}
|
}
|
||||||
tc = &TransportCodes{
|
tc = &TransportCodes{
|
||||||
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||||
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||||
}
|
}
|
||||||
offset += 4
|
offset += 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if offset >= len(buf) {
|
||||||
|
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||||
|
}
|
||||||
|
pathByte := buf[offset]
|
||||||
|
offset++
|
||||||
|
|
||||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||||
offset += bytesConsumed
|
offset += bytesConsumed
|
||||||
|
|
||||||
@@ -562,16 +593,24 @@ func ComputeContentHash(rawHex string) string {
|
|||||||
return rawHex
|
return rawHex
|
||||||
}
|
}
|
||||||
|
|
||||||
pathByte := buf[1]
|
headerByte := buf[0]
|
||||||
|
offset := 1
|
||||||
|
if isTransportRoute(int(headerByte & 0x03)) {
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
if offset >= len(buf) {
|
||||||
|
if len(rawHex) >= 16 {
|
||||||
|
return rawHex[:16]
|
||||||
|
}
|
||||||
|
return rawHex
|
||||||
|
}
|
||||||
|
pathByte := buf[offset]
|
||||||
|
offset++
|
||||||
hashSize := int((pathByte>>6)&0x3) + 1
|
hashSize := int((pathByte>>6)&0x3) + 1
|
||||||
hashCount := int(pathByte & 0x3F)
|
hashCount := int(pathByte & 0x3F)
|
||||||
pathBytes := hashSize * hashCount
|
pathBytes := hashSize * hashCount
|
||||||
|
|
||||||
headerByte := buf[0]
|
payloadStart := offset + pathBytes
|
||||||
payloadStart := 2 + pathBytes
|
|
||||||
if isTransportRoute(int(headerByte & 0x03)) {
|
|
||||||
payloadStart += 4
|
|
||||||
}
|
|
||||||
if payloadStart > len(buf) {
|
if payloadStart > len(buf) {
|
||||||
if len(rawHex) >= 16 {
|
if len(rawHex) >= 16 {
|
||||||
return rawHex[:16]
|
return rawHex[:16]
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ func TestDecodePath3ByteHashes(t *testing.T) {
|
|||||||
|
|
||||||
func TestTransportCodes(t *testing.T) {
|
func TestTransportCodes(t *testing.T) {
|
||||||
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
|
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
|
||||||
hex := "1400" + "AABB" + "CCDD" + "1A" + strings.Repeat("00", 10)
|
// Firmware order: header + transport_codes(4) + path_len + path + payload
|
||||||
|
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
|
||||||
pkt, err := DecodePacket(hex, nil)
|
pkt, err := DecodePacket(hex, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -140,11 +141,11 @@ func TestTransportCodes(t *testing.T) {
|
|||||||
if pkt.TransportCodes == nil {
|
if pkt.TransportCodes == nil {
|
||||||
t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD")
|
t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD")
|
||||||
}
|
}
|
||||||
if pkt.TransportCodes.NextHop != "AABB" {
|
if pkt.TransportCodes.Code1 != "AABB" {
|
||||||
t.Errorf("nextHop=%s, want AABB", pkt.TransportCodes.NextHop)
|
t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1)
|
||||||
}
|
}
|
||||||
if pkt.TransportCodes.LastHop != "CCDD" {
|
if pkt.TransportCodes.Code2 != "CCDD" {
|
||||||
t.Errorf("lastHop=%s, want CCDD", pkt.TransportCodes.LastHop)
|
t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route type 1 (FLOOD) should NOT have transport codes
|
// Route type 1 (FLOOD) should NOT have transport codes
|
||||||
@@ -537,10 +538,11 @@ func TestDecodeTraceShort(t *testing.T) {
|
|||||||
|
|
||||||
func TestDecodeTraceValid(t *testing.T) {
|
func TestDecodeTraceValid(t *testing.T) {
|
||||||
buf := make([]byte, 16)
|
buf := make([]byte, 16)
|
||||||
buf[0] = 0x00
|
// tag(4) + authCode(4) + flags(1) + pathData
|
||||||
buf[1] = 0x01 // tag LE uint32 = 1
|
binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1
|
||||||
buf[5] = 0xAA // destHash start
|
binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode
|
||||||
buf[11] = 0xBB
|
buf[8] = 0x02 // flags
|
||||||
|
buf[9] = 0xAA // path data
|
||||||
p := decodeTrace(buf)
|
p := decodeTrace(buf)
|
||||||
if p.Error != "" {
|
if p.Error != "" {
|
||||||
t.Errorf("unexpected error: %s", p.Error)
|
t.Errorf("unexpected error: %s", p.Error)
|
||||||
@@ -548,9 +550,18 @@ func TestDecodeTraceValid(t *testing.T) {
|
|||||||
if p.Tag != 1 {
|
if p.Tag != 1 {
|
||||||
t.Errorf("tag=%d, want 1", p.Tag)
|
t.Errorf("tag=%d, want 1", p.Tag)
|
||||||
}
|
}
|
||||||
|
if p.AuthCode != 0xDEADBEEF {
|
||||||
|
t.Errorf("authCode=%d, want 0xDEADBEEF", p.AuthCode)
|
||||||
|
}
|
||||||
|
if p.TraceFlags == nil || *p.TraceFlags != 2 {
|
||||||
|
t.Errorf("traceFlags=%v, want 2", p.TraceFlags)
|
||||||
|
}
|
||||||
if p.Type != "TRACE" {
|
if p.Type != "TRACE" {
|
||||||
t.Errorf("type=%s, want TRACE", p.Type)
|
t.Errorf("type=%s, want TRACE", p.Type)
|
||||||
}
|
}
|
||||||
|
if p.PathData == "" {
|
||||||
|
t.Error("pathData should not be empty")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodeAdvertShort(t *testing.T) {
|
func TestDecodeAdvertShort(t *testing.T) {
|
||||||
@@ -833,10 +844,9 @@ func TestComputeContentHashShortHex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeContentHashTransportRoute(t *testing.T) {
|
func TestComputeContentHashTransportRoute(t *testing.T) {
|
||||||
// Route type 0 (TRANSPORT_FLOOD) with no path hops + 4 transport code bytes
|
// Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops)
|
||||||
// header=0x14 (TRANSPORT_FLOOD, ADVERT), path=0x00 (0 hops)
|
// header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00
|
||||||
// transport codes = 4 bytes, then payload
|
hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10)
|
||||||
hex := "1400" + "AABBCCDD" + strings.Repeat("EE", 10)
|
|
||||||
hash := ComputeContentHash(hex)
|
hash := ComputeContentHash(hex)
|
||||||
if len(hash) != 16 {
|
if len(hash) != 16 {
|
||||||
t.Errorf("hash length=%d, want 16", len(hash))
|
t.Errorf("hash length=%d, want 16", len(hash))
|
||||||
@@ -870,12 +880,10 @@ func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) {
|
|||||||
|
|
||||||
func TestComputeContentHashTransportBeyondBuffer(t *testing.T) {
|
func TestComputeContentHashTransportBeyondBuffer(t *testing.T) {
|
||||||
// Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes
|
// Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes
|
||||||
// total buffer too short for transport codes + path
|
// header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash)
|
||||||
// header=0x00, pathByte=0x02 (2 hops, 1-byte hash), then only 2 more bytes
|
// offset=1+4+1+2=8, buffer needs to be >= 8
|
||||||
// payloadStart = 2 + 2 + 4(transport) = 8, but buffer only 6 bytes
|
hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
||||||
hex := "0002" + "AABB" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
|
||||||
hash := ComputeContentHash(hex)
|
hash := ComputeContentHash(hex)
|
||||||
// payloadStart = 2 + 2 + 4 = 8, buffer is 10 bytes → should work
|
|
||||||
if len(hash) != 16 {
|
if len(hash) != 16 {
|
||||||
t.Errorf("hash length=%d, want 16", len(hash))
|
t.Errorf("hash length=%d, want 16", len(hash))
|
||||||
}
|
}
|
||||||
@@ -913,8 +921,8 @@ func TestDecodePacketWithNewlines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
|
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
|
||||||
// TRANSPORT_FLOOD (route=0) but only 3 bytes total → too short for transport codes
|
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes
|
||||||
_, err := DecodePacket("140011", nil)
|
_, err := DecodePacket("1400", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for transport route with too-short buffer")
|
t.Error("expected error for transport route with too-short buffer")
|
||||||
}
|
}
|
||||||
@@ -931,16 +939,19 @@ func TestDecodeAckShort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodeAckValid(t *testing.T) {
|
func TestDecodeAckValid(t *testing.T) {
|
||||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD}
|
||||||
p := decodeAck(buf)
|
p := decodeAck(buf)
|
||||||
if p.Error != "" {
|
if p.Error != "" {
|
||||||
t.Errorf("unexpected error: %s", p.Error)
|
t.Errorf("unexpected error: %s", p.Error)
|
||||||
}
|
}
|
||||||
if p.DestHash != "aa" {
|
if p.ExtraHash != "ddccbbaa" {
|
||||||
t.Errorf("destHash=%s, want aa", p.DestHash)
|
t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash)
|
||||||
}
|
}
|
||||||
if p.ExtraHash != "ccddeeff" {
|
if p.DestHash != "" {
|
||||||
t.Errorf("extraHash=%s, want ccddeeff", p.ExtraHash)
|
t.Errorf("destHash should be empty, got %s", p.DestHash)
|
||||||
|
}
|
||||||
|
if p.SrcHash != "" {
|
||||||
|
t.Errorf("srcHash should be empty, got %s", p.SrcHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-13
@@ -23,6 +23,8 @@ func setupTestDBv2(t *testing.T) *DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Force single connection so all goroutines share the same in-memory DB
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
schema := `
|
schema := `
|
||||||
CREATE TABLE nodes (
|
CREATE TABLE nodes (
|
||||||
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
public_key TEXT PRIMARY KEY, name TEXT, role TEXT,
|
||||||
@@ -3426,9 +3428,9 @@ func TestIngestNewObservations(t *testing.T) {
|
|||||||
_ = newTxMax
|
_ = newTxMax
|
||||||
|
|
||||||
// IngestNewObservations should pick it up
|
// IngestNewObservations should pick it up
|
||||||
newObsMax := store.IngestNewObservations(maxObsID, 500)
|
newObsMaps := store.IngestNewObservations(maxObsID, 500)
|
||||||
if newObsMax <= maxObsID {
|
if len(newObsMaps) != 1 {
|
||||||
t.Errorf("expected newObsMax > %d, got %d", maxObsID, newObsMax)
|
t.Errorf("expected 1 observation broadcast map, got %d", len(newObsMaps))
|
||||||
}
|
}
|
||||||
if initialTx.ObservationCount != initialObsCount+1 {
|
if initialTx.ObservationCount != initialObsCount+1 {
|
||||||
t.Errorf("expected obs count %d, got %d", initialObsCount+1, initialTx.ObservationCount)
|
t.Errorf("expected obs count %d, got %d", initialObsCount+1, initialTx.ObservationCount)
|
||||||
@@ -3443,9 +3445,9 @@ func TestIngestNewObservations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("no new observations", func(t *testing.T) {
|
t.Run("no new observations", func(t *testing.T) {
|
||||||
max := store.IngestNewObservations(newObsMax, 500)
|
maps := store.IngestNewObservations(db.GetMaxObservationID(), 500)
|
||||||
if max != newObsMax {
|
if maps != nil {
|
||||||
t.Errorf("expected same max %d, got %d", newObsMax, max)
|
t.Errorf("expected nil maps for no new observations, got %d", len(maps))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3454,16 +3456,18 @@ func TestIngestNewObservations(t *testing.T) {
|
|||||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||||
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?)`, time.Now().Unix())
|
VALUES (1, 1, 12.5, -90, '["aa","bb"]', ?)`, time.Now().Unix())
|
||||||
prevCount := initialTx.ObservationCount
|
prevCount := initialTx.ObservationCount
|
||||||
newMax2 := store.IngestNewObservations(newObsMax, 500)
|
maps := store.IngestNewObservations(db.GetMaxObservationID()-1, 500)
|
||||||
if initialTx.ObservationCount != prevCount {
|
if initialTx.ObservationCount != prevCount {
|
||||||
t.Errorf("duplicate obs should not increase count, was %d now %d",
|
t.Errorf("duplicate obs should not increase count, was %d now %d",
|
||||||
prevCount, initialTx.ObservationCount)
|
prevCount, initialTx.ObservationCount)
|
||||||
}
|
}
|
||||||
_ = newMax2
|
if len(maps) != 0 {
|
||||||
|
t.Errorf("expected 0 broadcast maps for duplicate obs, got %d", len(maps))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("default limit", func(t *testing.T) {
|
t.Run("default limit", func(t *testing.T) {
|
||||||
_ = store.IngestNewObservations(newObsMax, 0)
|
_ = store.IngestNewObservations(db.GetMaxObservationID(), 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3486,9 +3490,9 @@ func TestIngestNewObservationsV2(t *testing.T) {
|
|||||||
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
|
db.conn.Exec(`INSERT INTO observations (transmission_id, observer_id, observer_name, snr, rssi, path_json, timestamp)
|
||||||
VALUES (1, 'obs2', 'Obs Two', 6.0, -98, '["dd","ee"]', ?)`, time.Now().Unix())
|
VALUES (1, 'obs2', 'Obs Two', 6.0, -98, '["dd","ee"]', ?)`, time.Now().Unix())
|
||||||
|
|
||||||
newMax := store.IngestNewObservations(maxObsID, 500)
|
newMaps := store.IngestNewObservations(maxObsID, 500)
|
||||||
if newMax <= maxObsID {
|
if len(newMaps) != 1 {
|
||||||
t.Errorf("expected newMax > %d, got %d", maxObsID, newMax)
|
t.Errorf("expected 1 observation broadcast map, got %d", len(newMaps))
|
||||||
}
|
}
|
||||||
if tx.ObservationCount != initialCount+1 {
|
if tx.ObservationCount != initialCount+1 {
|
||||||
t.Errorf("expected obs count %d, got %d", initialCount+1, tx.ObservationCount)
|
t.Errorf("expected obs count %d, got %d", initialCount+1, tx.ObservationCount)
|
||||||
@@ -3711,4 +3715,3 @@ func TestGetChannelMessagesAfterIngest(t *testing.T) {
|
|||||||
t.Errorf("newest message should be 'brand new message', got %q", lastMsg["text"])
|
t.Errorf("newest message should be 'brand new message', got %q", lastMsg["text"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ func setupTestDB(t *testing.T) *DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Force single connection so all goroutines share the same in-memory DB
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
|
||||||
// Create schema matching MeshCore Analyzer v3
|
// Create schema matching MeshCore Analyzer v3
|
||||||
schema := `
|
schema := `
|
||||||
|
|||||||
+55
-22
@@ -54,8 +54,8 @@ type Header struct {
|
|||||||
|
|
||||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||||
type TransportCodes struct {
|
type TransportCodes struct {
|
||||||
NextHop string `json:"nextHop"`
|
Code1 string `json:"code1"`
|
||||||
LastHop string `json:"lastHop"`
|
Code2 string `json:"code2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path holds decoded path/hop information.
|
// Path holds decoded path/hop information.
|
||||||
@@ -74,6 +74,8 @@ type AdvertFlags struct {
|
|||||||
Room bool `json:"room"`
|
Room bool `json:"room"`
|
||||||
Sensor bool `json:"sensor"`
|
Sensor bool `json:"sensor"`
|
||||||
HasLocation bool `json:"hasLocation"`
|
HasLocation bool `json:"hasLocation"`
|
||||||
|
HasFeat1 bool `json:"hasFeat1"`
|
||||||
|
HasFeat2 bool `json:"hasFeat2"`
|
||||||
HasName bool `json:"hasName"`
|
HasName bool `json:"hasName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +99,8 @@ type Payload struct {
|
|||||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||||
PathData string `json:"pathData,omitempty"`
|
PathData string `json:"pathData,omitempty"`
|
||||||
Tag uint32 `json:"tag,omitempty"`
|
Tag uint32 `json:"tag,omitempty"`
|
||||||
|
AuthCode uint32 `json:"authCode,omitempty"`
|
||||||
|
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||||
RawHex string `json:"raw,omitempty"`
|
RawHex string `json:"raw,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -173,14 +177,13 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeAck(buf []byte) Payload {
|
func decodeAck(buf []byte) Payload {
|
||||||
if len(buf) < 6 {
|
if len(buf) < 4 {
|
||||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||||
}
|
}
|
||||||
|
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||||
return Payload{
|
return Payload{
|
||||||
Type: "ACK",
|
Type: "ACK",
|
||||||
DestHash: hex.EncodeToString(buf[0:1]),
|
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
|
||||||
ExtraHash: hex.EncodeToString(buf[2:6]),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +208,8 @@ func decodeAdvert(buf []byte) Payload {
|
|||||||
if len(appdata) > 0 {
|
if len(appdata) > 0 {
|
||||||
flags := appdata[0]
|
flags := appdata[0]
|
||||||
advType := int(flags & 0x0F)
|
advType := int(flags & 0x0F)
|
||||||
|
hasFeat1 := flags&0x20 != 0
|
||||||
|
hasFeat2 := flags&0x40 != 0
|
||||||
p.Flags = &AdvertFlags{
|
p.Flags = &AdvertFlags{
|
||||||
Raw: int(flags),
|
Raw: int(flags),
|
||||||
Type: advType,
|
Type: advType,
|
||||||
@@ -213,6 +218,8 @@ func decodeAdvert(buf []byte) Payload {
|
|||||||
Room: advType == 3,
|
Room: advType == 3,
|
||||||
Sensor: advType == 4,
|
Sensor: advType == 4,
|
||||||
HasLocation: flags&0x10 != 0,
|
HasLocation: flags&0x10 != 0,
|
||||||
|
HasFeat1: hasFeat1,
|
||||||
|
HasFeat2: hasFeat2,
|
||||||
HasName: flags&0x80 != 0,
|
HasName: flags&0x80 != 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +233,12 @@ func decodeAdvert(buf []byte) Payload {
|
|||||||
p.Lon = &lon
|
p.Lon = &lon
|
||||||
off += 8
|
off += 8
|
||||||
}
|
}
|
||||||
|
if hasFeat1 && len(appdata) >= off+2 {
|
||||||
|
off += 2 // skip feat1 bytes (reserved for future use)
|
||||||
|
}
|
||||||
|
if hasFeat2 && len(appdata) >= off+2 {
|
||||||
|
off += 2 // skip feat2 bytes (reserved for future use)
|
||||||
|
}
|
||||||
if p.Flags.HasName {
|
if p.Flags.HasName {
|
||||||
name := string(appdata[off:])
|
name := string(appdata[off:])
|
||||||
name = strings.TrimRight(name, "\x00")
|
name = strings.TrimRight(name, "\x00")
|
||||||
@@ -276,15 +289,22 @@ func decodePathPayload(buf []byte) Payload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeTrace(buf []byte) Payload {
|
func decodeTrace(buf []byte) Payload {
|
||||||
if len(buf) < 12 {
|
if len(buf) < 9 {
|
||||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||||
}
|
}
|
||||||
return Payload{
|
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||||
Type: "TRACE",
|
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||||
DestHash: hex.EncodeToString(buf[5:11]),
|
flags := int(buf[8])
|
||||||
SrcHash: hex.EncodeToString(buf[11:12]),
|
p := Payload{
|
||||||
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
Type: "TRACE",
|
||||||
|
Tag: tag,
|
||||||
|
AuthCode: authCode,
|
||||||
|
TraceFlags: &flags,
|
||||||
}
|
}
|
||||||
|
if len(buf) > 9 {
|
||||||
|
p.PathData = hex.EncodeToString(buf[9:])
|
||||||
|
}
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodePayload(payloadType int, buf []byte) Payload {
|
func decodePayload(payloadType int, buf []byte) Payload {
|
||||||
@@ -327,8 +347,7 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header := decodeHeader(buf[0])
|
header := decodeHeader(buf[0])
|
||||||
pathByte := buf[1]
|
offset := 1
|
||||||
offset := 2
|
|
||||||
|
|
||||||
var tc *TransportCodes
|
var tc *TransportCodes
|
||||||
if isTransportRoute(header.RouteType) {
|
if isTransportRoute(header.RouteType) {
|
||||||
@@ -336,12 +355,18 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
|||||||
return nil, fmt.Errorf("packet too short for transport codes")
|
return nil, fmt.Errorf("packet too short for transport codes")
|
||||||
}
|
}
|
||||||
tc = &TransportCodes{
|
tc = &TransportCodes{
|
||||||
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||||
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||||
}
|
}
|
||||||
offset += 4
|
offset += 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if offset >= len(buf) {
|
||||||
|
return nil, fmt.Errorf("packet too short (no path byte)")
|
||||||
|
}
|
||||||
|
pathByte := buf[offset]
|
||||||
|
offset++
|
||||||
|
|
||||||
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
path, bytesConsumed := decodePath(pathByte, buf, offset)
|
||||||
offset += bytesConsumed
|
offset += bytesConsumed
|
||||||
|
|
||||||
@@ -367,16 +392,24 @@ func ComputeContentHash(rawHex string) string {
|
|||||||
return rawHex
|
return rawHex
|
||||||
}
|
}
|
||||||
|
|
||||||
pathByte := buf[1]
|
headerByte := buf[0]
|
||||||
|
offset := 1
|
||||||
|
if isTransportRoute(int(headerByte & 0x03)) {
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
if offset >= len(buf) {
|
||||||
|
if len(rawHex) >= 16 {
|
||||||
|
return rawHex[:16]
|
||||||
|
}
|
||||||
|
return rawHex
|
||||||
|
}
|
||||||
|
pathByte := buf[offset]
|
||||||
|
offset++
|
||||||
hashSize := int((pathByte>>6)&0x3) + 1
|
hashSize := int((pathByte>>6)&0x3) + 1
|
||||||
hashCount := int(pathByte & 0x3F)
|
hashCount := int(pathByte & 0x3F)
|
||||||
pathBytes := hashSize * hashCount
|
pathBytes := hashSize * hashCount
|
||||||
|
|
||||||
headerByte := buf[0]
|
payloadStart := offset + pathBytes
|
||||||
payloadStart := 2 + pathBytes
|
|
||||||
if isTransportRoute(int(headerByte & 0x03)) {
|
|
||||||
payloadStart += 4
|
|
||||||
}
|
|
||||||
if payloadStart > len(buf) {
|
if payloadStart > len(buf) {
|
||||||
if len(rawHex) >= 16 {
|
if len(rawHex) >= 16 {
|
||||||
return rawHex[:16]
|
return rawHex[:16]
|
||||||
|
|||||||
+107
-4
@@ -12,14 +12,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shapeSpec describes the expected JSON structure from the Node.js server.
|
// shapeSpec describes the expected JSON structure from the Node.js server.
|
||||||
type shapeSpec struct {
|
type shapeSpec struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Keys map[string]shapeSpec `json:"keys,omitempty"`
|
Keys map[string]shapeSpec `json:"keys,omitempty"`
|
||||||
ElementShape *shapeSpec `json:"elementShape,omitempty"`
|
ElementShape *shapeSpec `json:"elementShape,omitempty"`
|
||||||
DynamicKeys bool `json:"dynamicKeys,omitempty"`
|
DynamicKeys bool `json:"dynamicKeys,omitempty"`
|
||||||
ValueShape *shapeSpec `json:"valueShape,omitempty"`
|
ValueShape *shapeSpec `json:"valueShape,omitempty"`
|
||||||
@@ -138,8 +140,8 @@ func validateShape(actual interface{}, spec shapeSpec, path string) []string {
|
|||||||
|
|
||||||
// parityEndpoint defines one endpoint to test for parity.
|
// parityEndpoint defines one endpoint to test for parity.
|
||||||
type parityEndpoint struct {
|
type parityEndpoint struct {
|
||||||
name string // key in shapes.json
|
name string // key in shapes.json
|
||||||
path string // HTTP path to request
|
path string // HTTP path to request
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParityShapes(t *testing.T) {
|
func TestParityShapes(t *testing.T) {
|
||||||
@@ -401,3 +403,104 @@ func TestValidateShapeFunction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParityWSMultiObserverGolden(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
seedTestData(t, db)
|
||||||
|
hub := NewHub()
|
||||||
|
store := NewPacketStore(db)
|
||||||
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
poller := NewPoller(db, hub, 50*time.Millisecond)
|
||||||
|
poller.store = store
|
||||||
|
|
||||||
|
client := &Client{send: make(chan []byte, 256)}
|
||||||
|
hub.Register(client)
|
||||||
|
defer hub.Unregister(client)
|
||||||
|
|
||||||
|
go poller.Start()
|
||||||
|
defer poller.Stop()
|
||||||
|
|
||||||
|
// Wait for poller to initialize its lastID/lastObsID cursors before
|
||||||
|
// inserting new data; otherwise the poller may snapshot a lastID that
|
||||||
|
// already includes the test data and never broadcast it.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||||
|
VALUES ('BEEF', 'goldenstarburst237', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}')`, now); err != nil {
|
||||||
|
t.Fatalf("insert tx failed: %v", err)
|
||||||
|
}
|
||||||
|
var txID int
|
||||||
|
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='goldenstarburst237'`).Scan(&txID); err != nil {
|
||||||
|
t.Fatalf("query tx id failed: %v", err)
|
||||||
|
}
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||||
|
VALUES (?, 1, 11.0, -88, '["p1"]', ?),
|
||||||
|
(?, 2, 9.0, -92, '["p1","p2"]', ?),
|
||||||
|
(?, 1, 7.0, -96, '["p1","p2","p3"]', ?)`,
|
||||||
|
txID, ts, txID, ts+1, txID, ts+2); err != nil {
|
||||||
|
t.Fatalf("insert obs failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type golden struct {
|
||||||
|
Hash string
|
||||||
|
Count int
|
||||||
|
Paths []string
|
||||||
|
ObserverIDs []string
|
||||||
|
}
|
||||||
|
expected := golden{
|
||||||
|
Hash: "goldenstarburst237",
|
||||||
|
Count: 3,
|
||||||
|
Paths: []string{`["p1"]`, `["p1","p2"]`, `["p1","p2","p3"]`},
|
||||||
|
ObserverIDs: []string{"obs1", "obs2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
gotPaths := make([]string, 0, expected.Count)
|
||||||
|
gotObservers := make(map[string]bool)
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
for len(gotPaths) < expected.Count {
|
||||||
|
select {
|
||||||
|
case raw := <-client.send:
|
||||||
|
var msg map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
t.Fatalf("unmarshal ws message failed: %v", err)
|
||||||
|
}
|
||||||
|
if msg["type"] != "packet" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := msg["data"].(map[string]interface{})
|
||||||
|
if data == nil || data["hash"] != expected.Hash {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path, ok := data["path_json"].(string); ok {
|
||||||
|
gotPaths = append(gotPaths, path)
|
||||||
|
}
|
||||||
|
if oid, ok := data["observer_id"].(string); ok && oid != "" {
|
||||||
|
gotObservers[oid] = true
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out waiting for %d ws messages, got %d", expected.Count, len(gotPaths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(gotPaths)
|
||||||
|
sort.Strings(expected.Paths)
|
||||||
|
if len(gotPaths) != len(expected.Paths) {
|
||||||
|
t.Fatalf("path count mismatch: got %d want %d", len(gotPaths), len(expected.Paths))
|
||||||
|
}
|
||||||
|
for i := range expected.Paths {
|
||||||
|
if gotPaths[i] != expected.Paths[i] {
|
||||||
|
t.Fatalf("path mismatch at %d: got %q want %q", i, gotPaths[i], expected.Paths[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, oid := range expected.ObserverIDs {
|
||||||
|
if !gotObservers[oid] {
|
||||||
|
t.Fatalf("missing expected observer %q in ws messages", oid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+70
-35
@@ -1039,7 +1039,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build broadcast maps (same shape as Node.js WS broadcast)
|
// Build broadcast maps (same shape as Node.js WS broadcast), one per observation.
|
||||||
result := make([]map[string]interface{}, 0, len(broadcastOrder))
|
result := make([]map[string]interface{}, 0, len(broadcastOrder))
|
||||||
for _, txID := range broadcastOrder {
|
for _, txID := range broadcastOrder {
|
||||||
tx := broadcastTxs[txID]
|
tx := broadcastTxs[txID]
|
||||||
@@ -1055,32 +1055,34 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
decoded["payload"] = payload
|
decoded["payload"] = payload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Build the nested packet object (packets.js checks m.data.packet)
|
for _, obs := range tx.Observations {
|
||||||
pkt := map[string]interface{}{
|
// Build the nested packet object (packets.js checks m.data.packet)
|
||||||
"id": tx.ID,
|
pkt := map[string]interface{}{
|
||||||
"raw_hex": strOrNil(tx.RawHex),
|
"id": tx.ID,
|
||||||
"hash": strOrNil(tx.Hash),
|
"raw_hex": strOrNil(tx.RawHex),
|
||||||
"first_seen": strOrNil(tx.FirstSeen),
|
"hash": strOrNil(tx.Hash),
|
||||||
"timestamp": strOrNil(tx.FirstSeen),
|
"first_seen": strOrNil(tx.FirstSeen),
|
||||||
"route_type": intPtrOrNil(tx.RouteType),
|
"timestamp": strOrNil(tx.FirstSeen),
|
||||||
"payload_type": intPtrOrNil(tx.PayloadType),
|
"route_type": intPtrOrNil(tx.RouteType),
|
||||||
"decoded_json": strOrNil(tx.DecodedJSON),
|
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||||
"observer_id": strOrNil(tx.ObserverID),
|
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||||
"observer_name": strOrNil(tx.ObserverName),
|
"observer_id": strOrNil(obs.ObserverID),
|
||||||
"snr": floatPtrOrNil(tx.SNR),
|
"observer_name": strOrNil(obs.ObserverName),
|
||||||
"rssi": floatPtrOrNil(tx.RSSI),
|
"snr": floatPtrOrNil(obs.SNR),
|
||||||
"path_json": strOrNil(tx.PathJSON),
|
"rssi": floatPtrOrNil(obs.RSSI),
|
||||||
"direction": strOrNil(tx.Direction),
|
"path_json": strOrNil(obs.PathJSON),
|
||||||
"observation_count": tx.ObservationCount,
|
"direction": strOrNil(obs.Direction),
|
||||||
|
"observation_count": tx.ObservationCount,
|
||||||
|
}
|
||||||
|
// Broadcast map: top-level fields for live.js + nested packet for packets.js
|
||||||
|
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||||
|
for k, v := range pkt {
|
||||||
|
broadcastMap[k] = v
|
||||||
|
}
|
||||||
|
broadcastMap["decoded"] = decoded
|
||||||
|
broadcastMap["packet"] = pkt
|
||||||
|
result = append(result, broadcastMap)
|
||||||
}
|
}
|
||||||
// Broadcast map: top-level fields for live.js + nested packet for packets.js
|
|
||||||
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
|
||||||
for k, v := range pkt {
|
|
||||||
broadcastMap[k] = v
|
|
||||||
}
|
|
||||||
broadcastMap["decoded"] = decoded
|
|
||||||
broadcastMap["packet"] = pkt
|
|
||||||
result = append(result, broadcastMap)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate analytics caches since new data was ingested
|
// Invalidate analytics caches since new data was ingested
|
||||||
@@ -1101,7 +1103,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
|
|||||||
// IngestNewObservations loads new observations for transmissions already in the
|
// IngestNewObservations loads new observations for transmissions already in the
|
||||||
// store. This catches observations that arrive after IngestNewFromDB has already
|
// store. This catches observations that arrive after IngestNewFromDB has already
|
||||||
// advanced past the transmission's ID (fixes #174).
|
// advanced past the transmission's ID (fixes #174).
|
||||||
func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]interface{} {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 500
|
limit = 500
|
||||||
}
|
}
|
||||||
@@ -1127,7 +1129,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
|||||||
rows, err := s.db.conn.Query(querySQL, sinceObsID, limit)
|
rows, err := s.db.conn.Query(querySQL, sinceObsID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[store] ingest observations query error: %v", err)
|
log.Printf("[store] ingest observations query error: %v", err)
|
||||||
return sinceObsID
|
return nil
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
@@ -1170,20 +1172,16 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(obsRows) == 0 {
|
if len(obsRows) == 0 {
|
||||||
return sinceObsID
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
newMaxObsID := sinceObsID
|
|
||||||
updatedTxs := make(map[int]*StoreTx)
|
updatedTxs := make(map[int]*StoreTx)
|
||||||
|
broadcastMaps := make([]map[string]interface{}, 0, len(obsRows))
|
||||||
|
|
||||||
for _, r := range obsRows {
|
for _, r := range obsRows {
|
||||||
if r.obsID > newMaxObsID {
|
|
||||||
newMaxObsID = r.obsID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already ingested (e.g. by IngestNewFromDB in same cycle)
|
// Already ingested (e.g. by IngestNewFromDB in same cycle)
|
||||||
if _, exists := s.byObsID[r.obsID]; exists {
|
if _, exists := s.byObsID[r.obsID]; exists {
|
||||||
continue
|
continue
|
||||||
@@ -1226,6 +1224,43 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
|||||||
}
|
}
|
||||||
s.totalObs++
|
s.totalObs++
|
||||||
updatedTxs[r.txID] = tx
|
updatedTxs[r.txID] = tx
|
||||||
|
|
||||||
|
decoded := map[string]interface{}{
|
||||||
|
"header": map[string]interface{}{
|
||||||
|
"payloadTypeName": resolvePayloadTypeName(tx.PayloadType),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if tx.DecodedJSON != "" {
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(tx.DecodedJSON), &payload) == nil {
|
||||||
|
decoded["payload"] = payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt := map[string]interface{}{
|
||||||
|
"id": tx.ID,
|
||||||
|
"raw_hex": strOrNil(tx.RawHex),
|
||||||
|
"hash": strOrNil(tx.Hash),
|
||||||
|
"first_seen": strOrNil(tx.FirstSeen),
|
||||||
|
"timestamp": strOrNil(tx.FirstSeen),
|
||||||
|
"route_type": intPtrOrNil(tx.RouteType),
|
||||||
|
"payload_type": intPtrOrNil(tx.PayloadType),
|
||||||
|
"decoded_json": strOrNil(tx.DecodedJSON),
|
||||||
|
"observer_id": strOrNil(obs.ObserverID),
|
||||||
|
"observer_name": strOrNil(obs.ObserverName),
|
||||||
|
"snr": floatPtrOrNil(obs.SNR),
|
||||||
|
"rssi": floatPtrOrNil(obs.RSSI),
|
||||||
|
"path_json": strOrNil(obs.PathJSON),
|
||||||
|
"direction": strOrNil(obs.Direction),
|
||||||
|
"observation_count": tx.ObservationCount,
|
||||||
|
}
|
||||||
|
broadcastMap := make(map[string]interface{}, len(pkt)+2)
|
||||||
|
for k, v := range pkt {
|
||||||
|
broadcastMap[k] = v
|
||||||
|
}
|
||||||
|
broadcastMap["decoded"] = decoded
|
||||||
|
broadcastMap["packet"] = pkt
|
||||||
|
broadcastMaps = append(broadcastMaps, broadcastMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-pick best observation for updated transmissions and update subpath index
|
// Re-pick best observation for updated transmissions and update subpath index
|
||||||
@@ -1280,7 +1315,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
|
|||||||
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
// analytics caches cleared; no per-cycle log to avoid stdout overhead
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMaxObsID
|
return broadcastMaps
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxTransmissionID returns the highest transmission ID in the store.
|
// MaxTransmissionID returns the highest transmission ID in the store.
|
||||||
|
|||||||
+19
-3
@@ -181,9 +181,19 @@ func (p *Poller) Start() {
|
|||||||
lastID = newMax
|
lastID = newMax
|
||||||
}
|
}
|
||||||
// Ingest new observations for existing transmissions (fixes #174)
|
// Ingest new observations for existing transmissions (fixes #174)
|
||||||
newObsMax := p.store.IngestNewObservations(lastObsID, 500)
|
nextObsID := lastObsID
|
||||||
if newObsMax > lastObsID {
|
if err := p.db.conn.QueryRow(`
|
||||||
lastObsID = newObsMax
|
SELECT COALESCE(MAX(id), ?) FROM (
|
||||||
|
SELECT id FROM observations
|
||||||
|
WHERE id > ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 500
|
||||||
|
)`, lastObsID, lastObsID).Scan(&nextObsID); err != nil {
|
||||||
|
nextObsID = lastObsID
|
||||||
|
}
|
||||||
|
newObs := p.store.IngestNewObservations(lastObsID, 500)
|
||||||
|
if nextObsID > lastObsID {
|
||||||
|
lastObsID = nextObsID
|
||||||
}
|
}
|
||||||
if len(newTxs) > 0 {
|
if len(newTxs) > 0 {
|
||||||
log.Printf("[broadcast] sending %d packets to %d clients (lastID now %d)", len(newTxs), p.hub.ClientCount(), lastID)
|
log.Printf("[broadcast] sending %d packets to %d clients (lastID now %d)", len(newTxs), p.hub.ClientCount(), lastID)
|
||||||
@@ -194,6 +204,12 @@ func (p *Poller) Start() {
|
|||||||
Data: tx,
|
Data: tx,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for _, obs := range newObs {
|
||||||
|
p.hub.Broadcast(WSMessage{
|
||||||
|
Type: "packet",
|
||||||
|
Data: obs,
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct DB query (used when store is nil, e.g. tests)
|
// Fallback: direct DB query (used when store is nil, e.g. tests)
|
||||||
newTxs, err := p.db.GetNewTransmissionsSince(lastID, 100)
|
newTxs, err := p.db.GetNewTransmissionsSince(lastID, 100)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -250,6 +251,145 @@ func TestPollerBroadcastsNewData(t *testing.T) {
|
|||||||
hub.mu.Unlock()
|
hub.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPollerBroadcastsMultipleObservations(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
seedTestData(t, db)
|
||||||
|
hub := NewHub()
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
hub.mu.Lock()
|
||||||
|
hub.clients[client] = true
|
||||||
|
hub.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
hub.mu.Lock()
|
||||||
|
delete(hub.clients, client)
|
||||||
|
hub.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
poller := NewPoller(db, hub, 50*time.Millisecond)
|
||||||
|
store := NewPacketStore(db)
|
||||||
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store load failed: %v", err)
|
||||||
|
}
|
||||||
|
poller.store = store
|
||||||
|
go poller.Start()
|
||||||
|
defer poller.Stop()
|
||||||
|
|
||||||
|
// Wait for poller to initialize its lastID/lastObsID cursors before
|
||||||
|
// inserting new data; otherwise the poller may snapshot a lastID that
|
||||||
|
// already includes the test data and never broadcast it.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
|
||||||
|
VALUES ('FACE', 'starbursthash237a', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}')`, now); err != nil {
|
||||||
|
t.Fatalf("insert tx failed: %v", err)
|
||||||
|
}
|
||||||
|
var txID int
|
||||||
|
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='starbursthash237a'`).Scan(&txID); err != nil {
|
||||||
|
t.Fatalf("query tx id failed: %v", err)
|
||||||
|
}
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||||
|
VALUES (?, 1, 14.0, -82, '["aa"]', ?),
|
||||||
|
(?, 2, 10.5, -90, '["aa","bb"]', ?),
|
||||||
|
(?, 1, 7.0, -96, '["aa","bb","cc"]', ?)`,
|
||||||
|
txID, ts, txID, ts+1, txID, ts+2); err != nil {
|
||||||
|
t.Fatalf("insert observations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
var dataMsgs []map[string]interface{}
|
||||||
|
for len(dataMsgs) < 3 {
|
||||||
|
select {
|
||||||
|
case raw := <-client.send:
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
t.Fatalf("unmarshal ws msg failed: %v", err)
|
||||||
|
}
|
||||||
|
if parsed["type"] != "packet" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, ok := parsed["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if data["hash"] == "starbursthash237a" {
|
||||||
|
dataMsgs = append(dataMsgs, data)
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out waiting for 3 observation broadcasts, got %d", len(dataMsgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dataMsgs) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d", len(dataMsgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
paths := make([]string, 0, 3)
|
||||||
|
observers := make(map[string]bool)
|
||||||
|
for _, m := range dataMsgs {
|
||||||
|
hash, _ := m["hash"].(string)
|
||||||
|
if hash != "starbursthash237a" {
|
||||||
|
t.Fatalf("unexpected hash %q", hash)
|
||||||
|
}
|
||||||
|
p, _ := m["path_json"].(string)
|
||||||
|
paths = append(paths, p)
|
||||||
|
if oid, ok := m["observer_id"].(string); ok && oid != "" {
|
||||||
|
observers[oid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
wantPaths := []string{`["aa","bb","cc"]`, `["aa","bb"]`, `["aa"]`}
|
||||||
|
sort.Strings(wantPaths)
|
||||||
|
for i := range wantPaths {
|
||||||
|
if paths[i] != wantPaths[i] {
|
||||||
|
t.Fatalf("path mismatch at %d: got %q want %q", i, paths[i], wantPaths[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(observers) < 2 {
|
||||||
|
t.Fatalf("expected observations from >=2 observers, got %d", len(observers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestNewObservationsBroadcast(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
defer db.Close()
|
||||||
|
seedTestData(t, db)
|
||||||
|
store := NewPacketStore(db)
|
||||||
|
if err := store.Load(); err != nil {
|
||||||
|
t.Fatalf("store load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxObs := db.GetMaxObservationID()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
|
||||||
|
VALUES (1, 2, 6.0, -100, '["aa","zz"]', ?),
|
||||||
|
(1, 1, 5.0, -101, '["aa","yy"]', ?)`, now, now+1); err != nil {
|
||||||
|
t.Fatalf("insert new observations failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maps := store.IngestNewObservations(maxObs, 500)
|
||||||
|
if len(maps) != 2 {
|
||||||
|
t.Fatalf("expected 2 broadcast maps, got %d", len(maps))
|
||||||
|
}
|
||||||
|
for _, m := range maps {
|
||||||
|
if m["hash"] != "abc123def4567890" {
|
||||||
|
t.Fatalf("unexpected hash in map: %v", m["hash"])
|
||||||
|
}
|
||||||
|
path, ok := m["path_json"].(string)
|
||||||
|
if !ok || path == "" {
|
||||||
|
t.Fatalf("missing path_json in map: %#v", m)
|
||||||
|
}
|
||||||
|
if _, ok := m["observer_id"]; !ok {
|
||||||
|
t.Fatalf("missing observer_id in map: %#v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHubRegisterUnregister(t *testing.T) {
|
func TestHubRegisterUnregister(t *testing.T) {
|
||||||
hub := NewHub()
|
hub := NewHub()
|
||||||
|
|
||||||
|
|||||||
+33
-23
@@ -2,8 +2,8 @@
|
|||||||
* MeshCore Packet Decoder
|
* MeshCore Packet Decoder
|
||||||
* Custom implementation — does NOT use meshcore-decoder library (known path_length bug).
|
* Custom implementation — does NOT use meshcore-decoder library (known path_length bug).
|
||||||
*
|
*
|
||||||
* Packet layout:
|
* Packet layout (per firmware docs/packet_format.md):
|
||||||
* [header(1)] [pathLength(1)] [transportCodes?] [path hops] [payload...]
|
* [header(1)] [transportCodes?(4)] [pathLength(1)] [path hops] [payload...]
|
||||||
*
|
*
|
||||||
* Header byte (LSB first):
|
* Header byte (LSB first):
|
||||||
* bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT)
|
* bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT)
|
||||||
@@ -42,7 +42,7 @@ const PAYLOAD_TYPES = {
|
|||||||
0x0F: 'RAW_CUSTOM',
|
0x0F: 'RAW_CUSTOM',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Route types that carry transport codes (nextHop + lastHop, 2 bytes each)
|
// Route types that carry transport codes (2x uint16_t, 4 bytes total)
|
||||||
const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT
|
const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT
|
||||||
|
|
||||||
// --- Header parsing ---
|
// --- Header parsing ---
|
||||||
@@ -94,13 +94,11 @@ function decodeEncryptedPayload(buf) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ACK: dest(1) + src(1) + ack_hash(4) (per Mesh.cpp) */
|
/** ACK: checksum(4) — CRC of message timestamp + text + sender pubkey (per Mesh.cpp createAck) */
|
||||||
function decodeAck(buf) {
|
function decodeAck(buf) {
|
||||||
if (buf.length < 6) return { error: 'too short', raw: buf.toString('hex') };
|
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
||||||
return {
|
return {
|
||||||
destHash: buf.subarray(0, 1).toString('hex'),
|
ackChecksum: buf.subarray(0, 4).toString('hex'),
|
||||||
srcHash: buf.subarray(1, 2).toString('hex'),
|
|
||||||
extraHash: buf.subarray(2, 6).toString('hex'),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +123,8 @@ function decodeAdvert(buf) {
|
|||||||
room: advType === 3,
|
room: advType === 3,
|
||||||
sensor: advType === 4,
|
sensor: advType === 4,
|
||||||
hasLocation: !!(flags & 0x10),
|
hasLocation: !!(flags & 0x10),
|
||||||
|
hasFeat1: !!(flags & 0x20),
|
||||||
|
hasFeat2: !!(flags & 0x40),
|
||||||
hasName: !!(flags & 0x80),
|
hasName: !!(flags & 0x80),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,6 +134,14 @@ function decodeAdvert(buf) {
|
|||||||
result.lon = appdata.readInt32LE(off + 4) / 1e6;
|
result.lon = appdata.readInt32LE(off + 4) / 1e6;
|
||||||
off += 8;
|
off += 8;
|
||||||
}
|
}
|
||||||
|
if (result.flags.hasFeat1 && appdata.length >= off + 2) {
|
||||||
|
result.feat1 = appdata.readUInt16LE(off);
|
||||||
|
off += 2;
|
||||||
|
}
|
||||||
|
if (result.flags.hasFeat2 && appdata.length >= off + 2) {
|
||||||
|
result.feat2 = appdata.readUInt16LE(off);
|
||||||
|
off += 2;
|
||||||
|
}
|
||||||
if (result.flags.hasName) {
|
if (result.flags.hasName) {
|
||||||
// Find null terminator to separate name from trailing telemetry bytes
|
// Find null terminator to separate name from trailing telemetry bytes
|
||||||
let nameEnd = appdata.length;
|
let nameEnd = appdata.length;
|
||||||
@@ -231,7 +239,7 @@ function decodeGrpTxt(buf, channelKeys) {
|
|||||||
return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData };
|
return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */
|
/** ANON_REQ: dest(1) + ephemeral_pubkey(32) + MAC(2) + encrypted */
|
||||||
function decodeAnonReq(buf) {
|
function decodeAnonReq(buf) {
|
||||||
if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') };
|
if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') };
|
||||||
return {
|
return {
|
||||||
@@ -242,7 +250,7 @@ function decodeAnonReq(buf) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PATH: dest(6) + src(6) + MAC(4) + path_data */
|
/** PATH: dest(1) + src(1) + MAC(2) + path_data */
|
||||||
function decodePath_payload(buf) {
|
function decodePath_payload(buf) {
|
||||||
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
||||||
return {
|
return {
|
||||||
@@ -253,14 +261,14 @@ function decodePath_payload(buf) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TRACE: flags(1) + tag(4) + dest(6) + src(1) */
|
/** TRACE: tag(4) + authCode(4) + flags(1) + pathData (per Mesh.cpp onRecvPacket TRACE) */
|
||||||
function decodeTrace(buf) {
|
function decodeTrace(buf) {
|
||||||
if (buf.length < 12) return { error: 'too short', raw: buf.toString('hex') };
|
if (buf.length < 9) return { error: 'too short', raw: buf.toString('hex') };
|
||||||
return {
|
return {
|
||||||
flags: buf[0],
|
tag: buf.readUInt32LE(0),
|
||||||
tag: buf.readUInt32LE(1),
|
authCode: buf.subarray(4, 8).toString('hex'),
|
||||||
destHash: buf.subarray(5, 11).toString('hex'),
|
flags: buf[8],
|
||||||
srcHash: buf.subarray(11, 12).toString('hex'),
|
pathData: buf.subarray(9).toString('hex'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,20 +297,22 @@ function decodePacket(hexString, channelKeys) {
|
|||||||
if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)');
|
if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)');
|
||||||
|
|
||||||
const header = decodeHeader(buf[0]);
|
const header = decodeHeader(buf[0]);
|
||||||
const pathByte = buf[1];
|
let offset = 1;
|
||||||
let offset = 2;
|
|
||||||
|
|
||||||
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT
|
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT — BEFORE path_length per spec
|
||||||
let transportCodes = null;
|
let transportCodes = null;
|
||||||
if (TRANSPORT_ROUTES.has(header.routeType)) {
|
if (TRANSPORT_ROUTES.has(header.routeType)) {
|
||||||
if (buf.length < offset + 4) throw new Error('Packet too short for transport codes');
|
if (buf.length < offset + 4) throw new Error('Packet too short for transport codes');
|
||||||
transportCodes = {
|
transportCodes = {
|
||||||
nextHop: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
|
code1: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
|
||||||
lastHop: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
|
code2: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
|
||||||
};
|
};
|
||||||
offset += 4;
|
offset += 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path length byte — AFTER transport codes per spec
|
||||||
|
const pathByte = buf[offset++];
|
||||||
|
|
||||||
// Path
|
// Path
|
||||||
const path = decodePath(pathByte, buf, offset);
|
const path = decodePath(pathByte, buf, offset);
|
||||||
offset += path.bytesConsumed;
|
offset += path.bytesConsumed;
|
||||||
@@ -386,7 +396,7 @@ module.exports = { decodePacket, validateAdvert, hasNonPrintableChars, ROUTE_TYP
|
|||||||
|
|
||||||
// --- Tests ---
|
// --- Tests ---
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
|
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ===');
|
||||||
const pkt1 = decodePacket(
|
const pkt1 = decodePacket(
|
||||||
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
||||||
);
|
);
|
||||||
@@ -402,7 +412,7 @@ if (require.main === module) {
|
|||||||
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
||||||
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
||||||
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
||||||
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
|
assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"');
|
||||||
console.log('✅ Test 1 passed\n');
|
console.log('✅ Test 1 passed\n');
|
||||||
|
|
||||||
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
||||||
|
|||||||
+27
-27
@@ -22,9 +22,9 @@
|
|||||||
<meta name="twitter:title" content="CoreScope">
|
<meta name="twitter:title" content="CoreScope">
|
||||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||||
<link rel="stylesheet" href="style.css?v=1774731523">
|
<link rel="stylesheet" href="style.css?v=1774786038">
|
||||||
<link rel="stylesheet" href="home.css?v=1774731523">
|
<link rel="stylesheet" href="home.css?v=1774786038">
|
||||||
<link rel="stylesheet" href="live.css?v=1774731523">
|
<link rel="stylesheet" href="live.css?v=1774786038">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
crossorigin="anonymous">
|
crossorigin="anonymous">
|
||||||
@@ -81,29 +81,29 @@
|
|||||||
<main id="app" role="main"></main>
|
<main id="app" role="main"></main>
|
||||||
|
|
||||||
<script src="vendor/qrcode.js"></script>
|
<script src="vendor/qrcode.js"></script>
|
||||||
<script src="roles.js?v=1774731523"></script>
|
<script src="roles.js?v=1774786038"></script>
|
||||||
<script src="customize.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="region-filter.js?v=1774731523"></script>
|
<script src="region-filter.js?v=1774786038"></script>
|
||||||
<script src="hop-resolver.js?v=1774731523"></script>
|
<script src="hop-resolver.js?v=1774786038"></script>
|
||||||
<script src="hop-display.js?v=1774731523"></script>
|
<script src="hop-display.js?v=1774786038"></script>
|
||||||
<script src="app.js?v=1774731523"></script>
|
<script src="app.js?v=1774786038"></script>
|
||||||
<script src="home.js?v=1774731523"></script>
|
<script src="home.js?v=1774786038"></script>
|
||||||
<script src="packet-filter.js?v=1774731523"></script>
|
<script src="packet-filter.js?v=1774786038"></script>
|
||||||
<script src="packets.js?v=1774731523"></script>
|
<script src="packets.js?v=1774786038"></script>
|
||||||
<script src="map.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="channels.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="nodes.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="traces.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="analytics.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="audio.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="audio-v1-constellation.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="audio-v2-constellation.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="audio-lab.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="live.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="observers.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="observer-detail.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="compare.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="node-analytics.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="node-analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
<script src="perf.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+5
-7
@@ -1512,14 +1512,12 @@
|
|||||||
rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', '');
|
rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', '');
|
||||||
if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, '');
|
if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, '');
|
||||||
} else if (decoded.type === 'ACK') {
|
} else if (decoded.type === 'ACK') {
|
||||||
rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
|
rows += fieldRow(off, 'Checksum (4B)', decoded.ackChecksum || '', '');
|
||||||
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
|
|
||||||
rows += fieldRow(off + 12, 'Extra (6B)', decoded.extraHash || '', '');
|
|
||||||
} else if (decoded.destHash !== undefined) {
|
} else if (decoded.destHash !== undefined) {
|
||||||
rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
|
rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', '');
|
||||||
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
|
rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', '');
|
||||||
rows += fieldRow(off + 12, 'MAC (4B)', decoded.mac || '', '');
|
rows += fieldRow(off + 2, 'MAC (2B)', decoded.mac || '', '');
|
||||||
rows += fieldRow(off + 16, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
|
rows += fieldRow(off + 4, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
|
||||||
} else {
|
} else {
|
||||||
rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), '');
|
rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), '');
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -40,12 +40,12 @@
|
|||||||
html += `<h3>🔧 Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
html += `<h3>🔧 Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||||
<div class="perf-card"><div class="perf-num">${gr.goroutines}</div><div class="perf-label">Goroutines</div></div>
|
<div class="perf-card"><div class="perf-num">${gr.goroutines}</div><div class="perf-label">Goroutines</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.numGC}</div><div class="perf-label">GC Collections</div></div>
|
<div class="perf-card"><div class="perf-num">${gr.numGC}</div><div class="perf-label">GC Collections</div></div>
|
||||||
<div class="perf-card"><div class="perf-num" style="color:${gcColor}">${gr.pauseTotalMs}ms</div><div class="perf-label">GC Pause Total</div></div>
|
<div class="perf-card"><div class="perf-num" style="color:${gcColor}">${(+gr.pauseTotalMs).toFixed(1)}ms</div><div class="perf-label">GC Pause Total</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.lastPauseMs}ms</div><div class="perf-label">Last GC Pause</div></div>
|
<div class="perf-card"><div class="perf-num">${(+gr.lastPauseMs).toFixed(1)}ms</div><div class="perf-label">Last GC Pause</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.heapAllocMB}MB</div><div class="perf-label">Heap Alloc</div></div>
|
<div class="perf-card"><div class="perf-num">${(+gr.heapAllocMB).toFixed(1)}MB</div><div class="perf-label">Heap Alloc</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.heapSysMB}MB</div><div class="perf-label">Heap Sys</div></div>
|
<div class="perf-card"><div class="perf-num">${(+gr.heapSysMB).toFixed(1)}MB</div><div class="perf-label">Heap Sys</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.heapInuseMB}MB</div><div class="perf-label">Heap Inuse</div></div>
|
<div class="perf-card"><div class="perf-num">${(+gr.heapInuseMB).toFixed(1)}MB</div><div class="perf-label">Heap Inuse</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.heapIdleMB}MB</div><div class="perf-label">Heap Idle</div></div>
|
<div class="perf-card"><div class="perf-num">${(+gr.heapIdleMB).toFixed(1)}MB</div><div class="perf-label">Heap Idle</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${gr.numCPU}</div><div class="perf-label">CPUs</div></div>
|
<div class="perf-card"><div class="perf-num">${gr.numCPU}</div><div class="perf-label">CPUs</div></div>
|
||||||
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
|
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
|||||||
/* === Nav Stats === */
|
/* === Nav Stats === */
|
||||||
.nav-stats {
|
.nav-stats {
|
||||||
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
|
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
|
||||||
font-family: var(--mono); margin-right: 4px;
|
font-family: var(--mono); margin-right: 4px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
|
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
|
||||||
.nav-stats .stat-val.updated { color: var(--accent); }
|
.nav-stats .stat-val.updated { color: var(--accent); }
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ async function collectCoverage() {
|
|||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Home page — chooser...');
|
console.log(' [coverage] Home page — chooser...');
|
||||||
// Clear localStorage to get chooser
|
// Clear localStorage to get chooser
|
||||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
||||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Click "I'm new"
|
// Click "I'm new"
|
||||||
await safeClick('#chooseNew');
|
await safeClick('#chooseNew');
|
||||||
@@ -105,7 +105,7 @@ async function collectCoverage() {
|
|||||||
|
|
||||||
// Switch to experienced mode
|
// Switch to experienced mode
|
||||||
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
await page.evaluate(() => localStorage.clear()).catch(() => {});
|
||||||
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await safeClick('#chooseExp');
|
await safeClick('#chooseExp');
|
||||||
|
|
||||||
// Interact with experienced home page
|
// Interact with experienced home page
|
||||||
@@ -120,7 +120,7 @@ async function collectCoverage() {
|
|||||||
// NODES PAGE
|
// NODES PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Nodes page...');
|
console.log(' [coverage] Nodes page...');
|
||||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Sort by EVERY column
|
// Sort by EVERY column
|
||||||
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
|
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
|
||||||
@@ -168,7 +168,7 @@ async function collectCoverage() {
|
|||||||
try {
|
try {
|
||||||
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
|
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
|
||||||
if (firstNodeKey) {
|
if (firstNodeKey) {
|
||||||
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Click tabs on detail page
|
// Click tabs on detail page
|
||||||
await clickAll('.tab-btn, [data-tab]', 10);
|
await clickAll('.tab-btn, [data-tab]', 10);
|
||||||
@@ -191,7 +191,7 @@ async function collectCoverage() {
|
|||||||
try {
|
try {
|
||||||
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
|
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
|
||||||
if (firstKey) {
|
if (firstKey) {
|
||||||
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ async function collectCoverage() {
|
|||||||
// PACKETS PAGE
|
// PACKETS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Packets page...');
|
console.log(' [coverage] Packets page...');
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Open filter bar
|
// Open filter bar
|
||||||
await safeClick('#filterToggleBtn');
|
await safeClick('#filterToggleBtn');
|
||||||
@@ -285,13 +285,13 @@ async function collectCoverage() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Navigate to specific packet by hash
|
// Navigate to specific packet by hash
|
||||||
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// MAP PAGE
|
// MAP PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Map page...');
|
console.log(' [coverage] Map page...');
|
||||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Toggle controls panel
|
// Toggle controls panel
|
||||||
await safeClick('#mapControlsToggle');
|
await safeClick('#mapControlsToggle');
|
||||||
@@ -345,7 +345,7 @@ async function collectCoverage() {
|
|||||||
// ANALYTICS PAGE
|
// ANALYTICS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Analytics page...');
|
console.log(' [coverage] Analytics page...');
|
||||||
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Click EVERY analytics tab
|
// Click EVERY analytics tab
|
||||||
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
|
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
|
||||||
@@ -383,7 +383,7 @@ async function collectCoverage() {
|
|||||||
|
|
||||||
// Deep-link to each analytics tab via URL
|
// Deep-link to each analytics tab via URL
|
||||||
for (const tab of analyticsTabs) {
|
for (const tab of analyticsTabs) {
|
||||||
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Region filter on analytics
|
// Region filter on analytics
|
||||||
@@ -396,7 +396,7 @@ async function collectCoverage() {
|
|||||||
// CUSTOMIZE
|
// CUSTOMIZE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Customizer...');
|
console.log(' [coverage] Customizer...');
|
||||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await safeClick('#customizeToggle');
|
await safeClick('#customizeToggle');
|
||||||
|
|
||||||
// Click EVERY customizer tab
|
// Click EVERY customizer tab
|
||||||
@@ -503,7 +503,7 @@ async function collectCoverage() {
|
|||||||
// CHANNELS PAGE
|
// CHANNELS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Channels page...');
|
console.log(' [coverage] Channels page...');
|
||||||
await page.goto(`${BASE}/#/channels`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
// Click channel rows/items
|
// Click channel rows/items
|
||||||
await clickAll('.channel-item, .channel-row, .channel-card', 3);
|
await clickAll('.channel-item, .channel-row, .channel-card', 3);
|
||||||
await clickAll('table tbody tr', 3);
|
await clickAll('table tbody tr', 3);
|
||||||
@@ -512,7 +512,7 @@ async function collectCoverage() {
|
|||||||
try {
|
try {
|
||||||
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
|
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
|
||||||
if (channelHash) {
|
if (channelHash) {
|
||||||
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -520,7 +520,7 @@ async function collectCoverage() {
|
|||||||
// LIVE PAGE
|
// LIVE PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Live page...');
|
console.log(' [coverage] Live page...');
|
||||||
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// VCR controls
|
// VCR controls
|
||||||
await safeClick('#vcrPauseBtn');
|
await safeClick('#vcrPauseBtn');
|
||||||
@@ -603,14 +603,14 @@ async function collectCoverage() {
|
|||||||
// TRACES PAGE
|
// TRACES PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Traces page...');
|
console.log(' [coverage] Traces page...');
|
||||||
await page.goto(`${BASE}/#/traces`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/traces`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await clickAll('table tbody tr', 3);
|
await clickAll('table tbody tr', 3);
|
||||||
|
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
// OBSERVERS PAGE
|
// OBSERVERS PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Observers page...');
|
console.log(' [coverage] Observers page...');
|
||||||
await page.goto(`${BASE}/#/observers`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
// Click observer rows
|
// Click observer rows
|
||||||
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
|
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
|
||||||
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
|
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
|
||||||
@@ -631,7 +631,7 @@ async function collectCoverage() {
|
|||||||
// PERF PAGE
|
// PERF PAGE
|
||||||
// ══════════════════════════════════════════════
|
// ══════════════════════════════════════════════
|
||||||
console.log(' [coverage] Perf page...');
|
console.log(' [coverage] Perf page...');
|
||||||
await page.goto(`${BASE}/#/perf`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/perf`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await safeClick('#perfRefresh');
|
await safeClick('#perfRefresh');
|
||||||
await safeClick('#perfReset');
|
await safeClick('#perfReset');
|
||||||
|
|
||||||
@@ -641,14 +641,14 @@ async function collectCoverage() {
|
|||||||
console.log(' [coverage] App.js — router + global...');
|
console.log(' [coverage] App.js — router + global...');
|
||||||
|
|
||||||
// Navigate to bad route to trigger error/404
|
// Navigate to bad route to trigger error/404
|
||||||
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
|
|
||||||
// Navigate to every route via hash
|
// Navigate to every route via hash
|
||||||
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
|
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
|
||||||
for (const route of allRoutes) {
|
for (const route of allRoutes) {
|
||||||
try {
|
try {
|
||||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
await new Promise(r => setTimeout(r, 200));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,14 +788,14 @@ async function collectCoverage() {
|
|||||||
console.log(' [coverage] Region filter...');
|
console.log(' [coverage] Region filter...');
|
||||||
try {
|
try {
|
||||||
// Open region filter on nodes page
|
// Open region filter on nodes page
|
||||||
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await safeClick('#nodesRegionFilter');
|
await safeClick('#nodesRegionFilter');
|
||||||
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
|
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Region filter on packets
|
// Region filter on packets
|
||||||
try {
|
try {
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
|
||||||
await safeClick('#packetsRegionFilter');
|
await safeClick('#packetsRegionFilter');
|
||||||
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
|
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -807,7 +807,7 @@ async function collectCoverage() {
|
|||||||
for (const route of allRoutes) {
|
for (const route of allRoutes) {
|
||||||
try {
|
try {
|
||||||
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
await new Promise(r => setTimeout(r, 200));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,13 @@ class TTLCache {
|
|||||||
if (key.startsWith(prefix)) this.store.delete(key);
|
if (key.startsWith(prefix)) this.store.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
debouncedInvalidateBulkHealth() {
|
||||||
|
if (this._bulkHealthTimer) return;
|
||||||
|
this._bulkHealthTimer = setTimeout(() => {
|
||||||
|
this._bulkHealthTimer = null;
|
||||||
|
this.invalidate('bulk-health');
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
debouncedInvalidateAll() {
|
debouncedInvalidateAll() {
|
||||||
if (this._debounceTimer) return;
|
if (this._debounceTimer) return;
|
||||||
this._debounceTimer = setTimeout(() => {
|
this._debounceTimer = setTimeout(() => {
|
||||||
@@ -410,7 +417,7 @@ app.get('/api/perf', (req, res) => {
|
|||||||
avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0,
|
avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0,
|
||||||
endpoints: Object.fromEntries(sorted),
|
endpoints: Object.fromEntries(sorted),
|
||||||
slowQueries: perfStats.slowQueries.slice(-20),
|
slowQueries: perfStats.slowQueries.slice(-20),
|
||||||
cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0 },
|
cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + cache.misses) * 1000) / 10 : 0 },
|
||||||
packetStore: pktStore.getStats(),
|
packetStore: pktStore.getStats(),
|
||||||
sqlite: (() => {
|
sqlite: (() => {
|
||||||
try {
|
try {
|
||||||
@@ -519,7 +526,7 @@ app.get('/api/health', (req, res) => {
|
|||||||
misses: cache.misses,
|
misses: cache.misses,
|
||||||
staleHits: cache.staleHits,
|
staleHits: cache.staleHits,
|
||||||
recomputes: cache.recomputes,
|
recomputes: cache.recomputes,
|
||||||
hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0,
|
hitRate: cache.hits + cache.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + cache.misses) * 1000) / 10 : 0,
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
clients: wsClients,
|
clients: wsClients,
|
||||||
@@ -723,7 +730,7 @@ for (const source of mqttSources) {
|
|||||||
// Invalidate this node's caches on advert
|
// Invalidate this node's caches on advert
|
||||||
cache.invalidate('node:' + p.pubKey);
|
cache.invalidate('node:' + p.pubKey);
|
||||||
cache.invalidate('health:' + p.pubKey);
|
cache.invalidate('health:' + p.pubKey);
|
||||||
cache.invalidate('bulk-health');
|
cache.debouncedInvalidateBulkHealth();
|
||||||
|
|
||||||
// Cross-reference: if this node's pubkey matches an existing observer, backfill observer name
|
// Cross-reference: if this node's pubkey matches an existing observer, backfill observer name
|
||||||
if (p.name && p.pubKey) {
|
if (p.name && p.pubKey) {
|
||||||
|
|||||||
+11
-10
@@ -122,13 +122,14 @@ console.log('── Spec Tests: Transport Codes ──');
|
|||||||
|
|
||||||
{
|
{
|
||||||
// Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes
|
// Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes
|
||||||
// Route type 0: header byte = 0bPPPPPP00, e.g. 0x14 = payloadType 5 (GRP_TXT), routeType 0
|
// Route type 0: header=0x14 = payloadType 5 (GRP_TXT), routeType 0 (TRANSPORT_FLOOD)
|
||||||
const hex = '1400' + 'AABB' + 'CCDD' + '1A' + '00'.repeat(10); // transport codes + GRP_TXT payload
|
// Format: header(1) + transportCodes(4) + pathByte(1) + payload
|
||||||
|
const hex = '14' + 'AABB' + 'CCDD' + '00' + '1A' + '00'.repeat(10); // transport codes + pathByte + GRP_TXT payload
|
||||||
const p = decodePacket(hex);
|
const p = decodePacket(hex);
|
||||||
assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)');
|
assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)');
|
||||||
assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD');
|
assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD');
|
||||||
assertEq(p.transportCodes.nextHop, 'AABB', 'transport: nextHop');
|
assertEq(p.transportCodes.code1, 'AABB', 'transport: code1');
|
||||||
assertEq(p.transportCodes.lastHop, 'CCDD', 'transport: lastHop');
|
assertEq(p.transportCodes.code2, 'CCDD', 'transport: code2');
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -257,13 +258,13 @@ console.log('── Spec Tests: Advert Payload ──');
|
|||||||
|
|
||||||
console.log('── Spec Tests: Encrypted Payload Format ──');
|
console.log('── Spec Tests: Encrypted Payload Format ──');
|
||||||
|
|
||||||
// NOTE: Spec says v1 encrypted payloads have dest(1) + src(1) + MAC(2) + ciphertext
|
// Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher — decoder matches this.
|
||||||
// But decoder reads dest(6) + src(6) + MAC(4) + ciphertext
|
|
||||||
// This is a known discrepancy — the decoder matches production behavior, not the spec.
|
|
||||||
// The spec may describe the firmware's internal addressing while the OTA format differs,
|
|
||||||
// or the decoder may be parsing the fields differently. Production data validates the decoder.
|
|
||||||
{
|
{
|
||||||
note('Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher, but decoder reads dest(6)+src(6)+MAC(4)+cipher — decoder matches prod data');
|
const hex = '0100' + 'AA' + 'BB' + 'CCDD' + '00'.repeat(10);
|
||||||
|
const p = decodePacket(hex);
|
||||||
|
assertEq(p.payload.destHash, 'aa', 'encrypted payload: dest is 1 byte');
|
||||||
|
assertEq(p.payload.srcHash, 'bb', 'encrypted payload: src is 1 byte');
|
||||||
|
assertEq(p.payload.mac, 'ccdd', 'encrypted payload: MAC is 2 bytes');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('── Spec Tests: validateAdvert ──');
|
console.log('── Spec Tests: validateAdvert ──');
|
||||||
|
|||||||
+16
-16
@@ -28,22 +28,22 @@ test('FLOOD + ADVERT = 0x11', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('TRANSPORT_FLOOD = routeType 0', () => {
|
test('TRANSPORT_FLOOD = routeType 0', () => {
|
||||||
// 0x00 = TRANSPORT_FLOOD + REQ(0), needs transport codes + 16 byte payload
|
// header=0x00 (TRANSPORT_FLOOD + REQ), transportCodes=AABB+CCDD, pathByte=0x00, payload
|
||||||
const hex = '0000' + 'AABB' + 'CCDD' + '00'.repeat(16);
|
const hex = '00' + 'AABB' + 'CCDD' + '00' + '00'.repeat(16);
|
||||||
const p = decodePacket(hex);
|
const p = decodePacket(hex);
|
||||||
assert.strictEqual(p.header.routeType, 0);
|
assert.strictEqual(p.header.routeType, 0);
|
||||||
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD');
|
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD');
|
||||||
assert.notStrictEqual(p.transportCodes, null);
|
assert.notStrictEqual(p.transportCodes, null);
|
||||||
assert.strictEqual(p.transportCodes.nextHop, 'AABB');
|
assert.strictEqual(p.transportCodes.code1, 'AABB');
|
||||||
assert.strictEqual(p.transportCodes.lastHop, 'CCDD');
|
assert.strictEqual(p.transportCodes.code2, 'CCDD');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TRANSPORT_DIRECT = routeType 3', () => {
|
test('TRANSPORT_DIRECT = routeType 3', () => {
|
||||||
const hex = '0300' + '1122' + '3344' + '00'.repeat(16);
|
const hex = '03' + '1122' + '3344' + '00' + '00'.repeat(16);
|
||||||
const p = decodePacket(hex);
|
const p = decodePacket(hex);
|
||||||
assert.strictEqual(p.header.routeType, 3);
|
assert.strictEqual(p.header.routeType, 3);
|
||||||
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT');
|
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT');
|
||||||
assert.strictEqual(p.transportCodes.nextHop, '1122');
|
assert.strictEqual(p.transportCodes.code1, '1122');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('DIRECT = routeType 2, no transport codes', () => {
|
test('DIRECT = routeType 2, no transport codes', () => {
|
||||||
@@ -358,9 +358,7 @@ test('ACK decode', () => {
|
|||||||
const hex = '0D00' + '00'.repeat(18);
|
const hex = '0D00' + '00'.repeat(18);
|
||||||
const p = decodePacket(hex);
|
const p = decodePacket(hex);
|
||||||
assert.strictEqual(p.payload.type, 'ACK');
|
assert.strictEqual(p.payload.type, 'ACK');
|
||||||
assert(p.payload.destHash);
|
assert(p.payload.ackChecksum);
|
||||||
assert(p.payload.srcHash);
|
|
||||||
assert(p.payload.extraHash);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ACK too short', () => {
|
test('ACK too short', () => {
|
||||||
@@ -424,9 +422,9 @@ test('TRACE decode', () => {
|
|||||||
const hex = '2500' + '00'.repeat(12);
|
const hex = '2500' + '00'.repeat(12);
|
||||||
const p = decodePacket(hex);
|
const p = decodePacket(hex);
|
||||||
assert.strictEqual(p.payload.type, 'TRACE');
|
assert.strictEqual(p.payload.type, 'TRACE');
|
||||||
assert.strictEqual(p.payload.flags, 0);
|
|
||||||
assert(p.payload.tag !== undefined);
|
assert(p.payload.tag !== undefined);
|
||||||
assert(p.payload.destHash);
|
assert(p.payload.authCode !== undefined);
|
||||||
|
assert.strictEqual(p.payload.flags, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TRACE too short', () => {
|
test('TRACE too short', () => {
|
||||||
@@ -460,16 +458,18 @@ test('Transport route too short throws', () => {
|
|||||||
assert.throws(() => decodePacket('0000'), /too short for transport/);
|
assert.throws(() => decodePacket('0000'), /too short for transport/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Corrupt packet #183 — path overflow capped to buffer', () => {
|
test('Corrupt packet #183 — TRANSPORT_DIRECT with correct field order', () => {
|
||||||
const hex = 'BBAD6797EC8751D500BF95A1A776EF580E665BCBF6A0BBE03B5E730707C53489B8C728FD3FB902397197E1263CEC21E52465362243685DBBAD6797EC8751C90A75D9FD8213155D';
|
const hex = 'BBAD6797EC8751D500BF95A1A776EF580E665BCBF6A0BBE03B5E730707C53489B8C728FD3FB902397197E1263CEC21E52465362243685DBBAD6797EC8751C90A75D9FD8213155D';
|
||||||
const p = decodePacket(hex);
|
const p = decodePacket(hex);
|
||||||
assert.strictEqual(p.header.routeType, 3, 'routeType should be TRANSPORT_DIRECT');
|
assert.strictEqual(p.header.routeType, 3, 'routeType should be TRANSPORT_DIRECT');
|
||||||
assert.strictEqual(p.header.payloadTypeName, 'UNKNOWN');
|
assert.strictEqual(p.header.payloadTypeName, 'UNKNOWN');
|
||||||
// pathByte 0xAD claims 45 hops × 3 bytes = 135, but only 65 bytes available
|
// transport codes are bytes 1-4, pathByte=0x87 at byte 5
|
||||||
|
assert.strictEqual(p.transportCodes.code1, 'AD67');
|
||||||
|
assert.strictEqual(p.transportCodes.code2, '97EC');
|
||||||
|
// pathByte 0x87: hashSize=3, hashCount=7
|
||||||
assert.strictEqual(p.path.hashSize, 3);
|
assert.strictEqual(p.path.hashSize, 3);
|
||||||
assert.strictEqual(p.path.hashCount, 21, 'hashCount capped to fit buffer');
|
assert.strictEqual(p.path.hashCount, 7);
|
||||||
assert.strictEqual(p.path.hops.length, 21);
|
assert.strictEqual(p.path.hops.length, 7);
|
||||||
assert.strictEqual(p.path.truncated, true);
|
|
||||||
// No empty strings in hops
|
// No empty strings in hops
|
||||||
assert(p.path.hops.every(h => h.length > 0), 'no empty hops');
|
assert(p.path.hops.every(h => h.length > 0), 'no empty hops');
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-11
@@ -10,13 +10,21 @@ const GO_BASE = process.env.GO_BASE_URL || ''; // e.g. https://analyzer.00id.ne
|
|||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
async function test(name, fn) {
|
async function test(name, fn) {
|
||||||
try {
|
const MAX_RETRIES = 2;
|
||||||
await fn();
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
results.push({ name, pass: true });
|
try {
|
||||||
console.log(` \u2705 ${name}`);
|
await fn();
|
||||||
} catch (err) {
|
results.push({ name, pass: true });
|
||||||
results.push({ name, pass: false, error: err.message });
|
console.log(` \u2705 ${name}${attempt > 1 ? ` (retry ${attempt - 1})` : ''}`);
|
||||||
console.log(` \u274c ${name}: ${err.message}`);
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
console.log(` \u26a0\ufe0f ${name}: ${err.message} (retrying...)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push({ name, pass: false, error: err.message });
|
||||||
|
console.log(` \u274c ${name}: ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +332,9 @@ async function run() {
|
|||||||
|
|
||||||
// Test: Packets groupByHash toggle changes view
|
// Test: Packets groupByHash toggle changes view
|
||||||
await test('Packets groupByHash toggle works', async () => {
|
await test('Packets groupByHash toggle works', async () => {
|
||||||
await page.waitForSelector('table tbody tr');
|
// Fresh navigation to ensure clean state
|
||||||
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('table tbody tr', { timeout: 15000 });
|
||||||
const groupBtn = await page.$('#fGroup');
|
const groupBtn = await page.$('#fGroup');
|
||||||
assert(groupBtn, 'Group by hash button (#fGroup) not found');
|
assert(groupBtn, 'Group by hash button (#fGroup) not found');
|
||||||
// Check initial state (default is grouped/active)
|
// Check initial state (default is grouped/active)
|
||||||
@@ -354,10 +364,17 @@ async function run() {
|
|||||||
await test('Packets clicking row shows detail pane', async () => {
|
await test('Packets clicking row shows detail pane', async () => {
|
||||||
// Fresh navigation to avoid stale row references from previous test
|
// Fresh navigation to avoid stale row references from previous test
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||||
|
// Wait for table rows AND initial API data to settle
|
||||||
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
const firstRow = await page.$('table tbody tr[data-action]');
|
const firstRow = await page.$('table tbody tr[data-action]');
|
||||||
assert(firstRow, 'No clickable packet rows found');
|
assert(firstRow, 'No clickable packet rows found');
|
||||||
await firstRow.click();
|
// Click the row and wait for the /packets/{hash} API response
|
||||||
|
const [response] = await Promise.all([
|
||||||
|
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
|
||||||
|
firstRow.click(),
|
||||||
|
]);
|
||||||
|
assert(response, 'API response for packet detail not received');
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const panel = document.getElementById('pktRight');
|
const panel = document.getElementById('pktRight');
|
||||||
return panel && !panel.classList.contains('empty');
|
return panel && !panel.classList.contains('empty');
|
||||||
@@ -375,12 +392,16 @@ async function run() {
|
|||||||
if (!pktRight) {
|
if (!pktRight) {
|
||||||
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
await page.waitForSelector('table tbody tr[data-action]', { timeout: 15000 });
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
}
|
}
|
||||||
const panelOpen = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
|
const panelOpen = await page.$eval('#pktRight', el => !el.classList.contains('empty'));
|
||||||
if (!panelOpen) {
|
if (!panelOpen) {
|
||||||
const firstRow = await page.$('table tbody tr[data-action]');
|
const firstRow = await page.$('table tbody tr[data-action]');
|
||||||
if (!firstRow) { console.log(' ⏭️ Skipped (no clickable rows)'); return; }
|
if (!firstRow) { console.log(' ⏭️ Skipped (no clickable rows)'); return; }
|
||||||
await firstRow.click();
|
await Promise.all([
|
||||||
|
page.waitForResponse(resp => resp.url().includes('/packets/') && resp.status() === 200, { timeout: 15000 }),
|
||||||
|
firstRow.click(),
|
||||||
|
]);
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const panel = document.getElementById('pktRight');
|
const panel = document.getElementById('pktRight');
|
||||||
return panel && !panel.classList.contains('empty');
|
return panel && !panel.classList.contains('empty');
|
||||||
@@ -523,6 +544,11 @@ async function run() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test('Compare page runs comparison', async () => {
|
await test('Compare page runs comparison', async () => {
|
||||||
|
// Wait for dropdowns to be populated (may still be loading from previous test)
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const selA = document.getElementById('compareObsA');
|
||||||
|
return selA && selA.options.length > 2;
|
||||||
|
}, { timeout: 10000 });
|
||||||
const options = await page.$$eval('#compareObsA option', opts =>
|
const options = await page.$$eval('#compareObsA option', opts =>
|
||||||
opts.filter(o => o.value).map(o => o.value)
|
opts.filter(o => o.value).map(o => o.value)
|
||||||
);
|
);
|
||||||
@@ -544,6 +570,12 @@ async function run() {
|
|||||||
|
|
||||||
// Test: Compare results show shared/unique breakdown (#129)
|
// Test: Compare results show shared/unique breakdown (#129)
|
||||||
await test('Compare results show shared/unique cards', async () => {
|
await test('Compare results show shared/unique cards', async () => {
|
||||||
|
// Wait for comparison results to fully render (depends on previous test)
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
return document.querySelector('.compare-card-both') &&
|
||||||
|
document.querySelector('.compare-card-a') &&
|
||||||
|
document.querySelector('.compare-card-b');
|
||||||
|
}, { timeout: 10000 });
|
||||||
// Results should be visible from previous test
|
// Results should be visible from previous test
|
||||||
const cardBoth = await page.$('.compare-card-both');
|
const cardBoth = await page.$('.compare-card-both');
|
||||||
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
|
assert(cardBoth, 'Should have "shared" card (.compare-card-both)');
|
||||||
@@ -566,6 +598,11 @@ async function run() {
|
|||||||
|
|
||||||
// Test: Compare "both" tab shows table with shared packets
|
// Test: Compare "both" tab shows table with shared packets
|
||||||
await test('Compare both tab shows shared packets table', async () => {
|
await test('Compare both tab shows shared packets table', async () => {
|
||||||
|
// Ensure compare results are present
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const c = document.getElementById('compareContent');
|
||||||
|
return c && c.textContent.trim().length > 20;
|
||||||
|
}, { timeout: 10000 });
|
||||||
const bothTab = await page.$('[data-cview="both"]');
|
const bothTab = await page.$('[data-cview="both"]');
|
||||||
assert(bothTab, '"both" tab button not found');
|
assert(bothTab, '"both" tab button not found');
|
||||||
await bothTab.click();
|
await bothTab.click();
|
||||||
@@ -789,7 +826,11 @@ async function run() {
|
|||||||
// Check for summary stats
|
// Check for summary stats
|
||||||
const summary = await page.$('.obs-summary');
|
const summary = await page.$('.obs-summary');
|
||||||
assert(summary, 'Observer summary stats not found');
|
assert(summary, 'Observer summary stats not found');
|
||||||
// Verify table has rows
|
// Wait for table rows to populate
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const rows = document.querySelectorAll('#obsTable tbody tr');
|
||||||
|
return rows.length > 0;
|
||||||
|
}, { timeout: 10000 });
|
||||||
const rows = await page.$$('#obsTable tbody tr');
|
const rows = await page.$$('#obsTable tbody tr');
|
||||||
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
|
assert(rows.length > 0, `Expected >=1 observer rows, got ${rows.length}`);
|
||||||
});
|
});
|
||||||
@@ -909,6 +950,19 @@ async function run() {
|
|||||||
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
assert(hexDump, 'Hex dump should be visible after selecting a packet');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract frontend coverage if instrumented server is running
|
||||||
|
try {
|
||||||
|
const coverage = await page.evaluate(() => window.__coverage__);
|
||||||
|
if (coverage) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const outDir = path.join(__dirname, '.nyc_output');
|
||||||
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outDir, 'e2e-coverage.json'), JSON.stringify(coverage));
|
||||||
|
console.log(`Frontend coverage from E2E: ${Object.keys(coverage).length} files`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
|
|||||||
@@ -1322,7 +1322,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
assert.ok(result.includes('>v2.6.0</a>'), 'version text has v prefix');
|
assert.ok(result.includes('>v2.6.0</a>'), 'version text has v prefix');
|
||||||
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit links to full hash');
|
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit links to full hash');
|
||||||
assert.ok(result.includes('>abc1234</a>'), 'commit display is truncated to 7');
|
assert.ok(result.includes('>abc1234</a>'), 'commit display is truncated to 7');
|
||||||
assert.ok(result.includes('[node]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('prod port 80: shows version', () => {
|
test('prod port 80: shows version', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('80');
|
const { formatVersionBadge } = makeBadgeSandbox('80');
|
||||||
@@ -1348,7 +1348,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
assert.ok(!result.includes('v2.6.0'), 'staging should NOT show version');
|
assert.ok(!result.includes('v2.6.0'), 'staging should NOT show version');
|
||||||
assert.ok(result.includes('>abc1234</a>'), 'should show commit hash');
|
assert.ok(result.includes('>abc1234</a>'), 'should show commit hash');
|
||||||
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit is linked');
|
assert.ok(result.includes(`href="${GH}/commit/abc1234def5678"`), 'commit is linked');
|
||||||
assert.ok(result.includes('[go]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('staging port 81: hides version', () => {
|
test('staging port 81: hides version', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('81');
|
const { formatVersionBadge } = makeBadgeSandbox('81');
|
||||||
@@ -1369,18 +1369,18 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
const result = formatVersionBadge('2.6.0', 'unknown', 'node');
|
const result = formatVersionBadge('2.6.0', 'unknown', 'node');
|
||||||
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
||||||
assert.ok(!result.includes('unknown'), 'should not show unknown commit');
|
assert.ok(!result.includes('unknown'), 'should not show unknown commit');
|
||||||
assert.ok(result.includes('[node]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>node<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('skips commit when missing', () => {
|
test('skips commit when missing', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('');
|
const { formatVersionBadge } = makeBadgeSandbox('');
|
||||||
const result = formatVersionBadge('2.6.0', null, 'go');
|
const result = formatVersionBadge('2.6.0', null, 'go');
|
||||||
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
assert.ok(result.includes('>v2.6.0</a>'), 'should show version');
|
||||||
assert.ok(result.includes('[go]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||||
});
|
});
|
||||||
test('shows only engine when version/commit missing', () => {
|
test('shows only engine when version/commit missing', () => {
|
||||||
const { formatVersionBadge } = makeBadgeSandbox('3000');
|
const { formatVersionBadge } = makeBadgeSandbox('3000');
|
||||||
const result = formatVersionBadge(null, null, 'go');
|
const result = formatVersionBadge(null, null, 'go');
|
||||||
assert.ok(result.includes('[go]'), 'should show engine');
|
assert.ok(result.includes('engine-badge'), 'should show engine badge'); assert.ok(result.includes('>go<'), 'should show engine name');
|
||||||
assert.ok(result.includes('version-badge'), 'should use version-badge class');
|
assert.ok(result.includes('version-badge'), 'should use version-badge class');
|
||||||
});
|
});
|
||||||
test('short commit not truncated in display', () => {
|
test('short commit not truncated in display', () => {
|
||||||
@@ -1398,7 +1398,7 @@ console.log('\n=== app.js: formatVersionBadge ===');
|
|||||||
const { formatVersionBadge } = makeBadgeSandbox('8080');
|
const { formatVersionBadge } = makeBadgeSandbox('8080');
|
||||||
const result = formatVersionBadge('2.6.0', null, 'go');
|
const result = formatVersionBadge('2.6.0', null, 'go');
|
||||||
assert.ok(!result.includes('2.6.0'), 'no version on staging');
|
assert.ok(!result.includes('2.6.0'), 'no version on staging');
|
||||||
assert.ok(result.includes('[go]'), 'engine shown');
|
assert.ok(result.includes('engine-badge'), 'engine badge shown'); assert.ok(result.includes('>go<'), 'engine name shown');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1254,6 +1254,24 @@ seedTestData();
|
|||||||
lastPathSeenMap.delete(liveNode);
|
lastPathSeenMap.delete(liveNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Cache hit rate includes stale hits ──
|
||||||
|
await t('Cache hitRate includes staleHits in formula', async () => {
|
||||||
|
cache.clear();
|
||||||
|
cache.hits = 0;
|
||||||
|
cache.misses = 0;
|
||||||
|
cache.staleHits = 0;
|
||||||
|
// Simulate: 3 hits, 2 stale hits, 5 misses => rate = (3+2)/(3+2+5) = 50%
|
||||||
|
cache.hits = 3;
|
||||||
|
cache.staleHits = 2;
|
||||||
|
cache.misses = 5;
|
||||||
|
const r = await request(app).get('/api/health').expect(200);
|
||||||
|
assert(r.body.cache.hitRate === 50, 'hitRate should be (hits+staleHits)/(hits+staleHits+misses) = 50%, got ' + r.body.cache.hitRate);
|
||||||
|
// Reset
|
||||||
|
cache.hits = 0;
|
||||||
|
cache.misses = 0;
|
||||||
|
cache.staleHits = 0;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Summary ──
|
// ── Summary ──
|
||||||
console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`);
|
console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`);
|
||||||
if (failed > 0) process.exit(1);
|
if (failed > 0) process.exit(1);
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed synthetic test data into a running CoreScope server.
|
||||||
|
* Usage: node tools/seed-test-data.js [baseUrl]
|
||||||
|
* Default: http://localhost:13581
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const BASE = process.argv[2] || process.env.BASE_URL || 'http://localhost:13581';
|
||||||
|
|
||||||
|
const OBSERVERS = [
|
||||||
|
{ id: 'E2E-SJC-1', iata: 'SJC' },
|
||||||
|
{ id: 'E2E-SFO-2', iata: 'SFO' },
|
||||||
|
{ id: 'E2E-OAK-3', iata: 'OAK' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NODE_NAMES = [
|
||||||
|
'TestNode Alpha', 'TestNode Beta', 'TestNode Gamma', 'TestNode Delta',
|
||||||
|
'TestNode Epsilon', 'TestNode Zeta', 'TestNode Eta', 'TestNode Theta',
|
||||||
|
];
|
||||||
|
|
||||||
|
function rand(a, b) { return Math.random() * (b - a) + a; }
|
||||||
|
function randInt(a, b) { return Math.floor(rand(a, b + 1)); }
|
||||||
|
function pick(a) { return a[randInt(0, a.length - 1)]; }
|
||||||
|
function randomBytes(n) { return crypto.randomBytes(n); }
|
||||||
|
function pubkeyFor(name) { return crypto.createHash('sha256').update(name).digest(); }
|
||||||
|
|
||||||
|
function encodeHeader(routeType, payloadType, ver = 0) {
|
||||||
|
return (routeType & 0x03) | ((payloadType & 0x0F) << 2) | ((ver & 0x03) << 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPath(hopCount, hashSize = 2) {
|
||||||
|
const pathByte = ((hashSize - 1) << 6) | (hopCount & 0x3F);
|
||||||
|
const hops = crypto.randomBytes(hashSize * hopCount);
|
||||||
|
return { pathByte, hops };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdvert(name, role) {
|
||||||
|
const pubKey = pubkeyFor(name);
|
||||||
|
const ts = Buffer.alloc(4); ts.writeUInt32LE(Math.floor(Date.now() / 1000));
|
||||||
|
const sig = randomBytes(64);
|
||||||
|
let flags = 0x80 | 0x10; // hasName + hasLocation
|
||||||
|
if (role === 'repeater') flags |= 0x02;
|
||||||
|
else if (role === 'room') flags |= 0x04;
|
||||||
|
else if (role === 'sensor') flags |= 0x08;
|
||||||
|
else flags |= 0x01;
|
||||||
|
const nameBuf = Buffer.from(name, 'utf8');
|
||||||
|
const appdata = Buffer.alloc(9 + nameBuf.length);
|
||||||
|
appdata[0] = flags;
|
||||||
|
appdata.writeInt32LE(Math.round(37.34 * 1e6), 1);
|
||||||
|
appdata.writeInt32LE(Math.round(-121.89 * 1e6), 5);
|
||||||
|
nameBuf.copy(appdata, 9);
|
||||||
|
const payload = Buffer.concat([pubKey, ts, sig, appdata]);
|
||||||
|
const header = encodeHeader(1, 0x04, 0); // FLOOD + ADVERT
|
||||||
|
const { pathByte, hops } = buildPath(randInt(0, 3));
|
||||||
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGrpTxt(channelHash = 0) {
|
||||||
|
const mac = randomBytes(2);
|
||||||
|
const enc = randomBytes(randInt(10, 40));
|
||||||
|
const payload = Buffer.concat([Buffer.from([channelHash]), mac, enc]);
|
||||||
|
const header = encodeHeader(1, 0x05, 0); // FLOOD + GRP_TXT
|
||||||
|
const { pathByte, hops } = buildPath(randInt(0, 3));
|
||||||
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a properly encrypted GRP_TXT packet that decrypts to a CHAN message.
|
||||||
|
* Uses #LongFast channel key from channel-rainbow.json.
|
||||||
|
*/
|
||||||
|
function buildEncryptedGrpTxt(sender, message) {
|
||||||
|
try {
|
||||||
|
const CryptoJS = require('crypto-js');
|
||||||
|
const { ChannelCrypto } = require('@michaelhart/meshcore-decoder/dist/crypto/channel-crypto');
|
||||||
|
|
||||||
|
const channelKey = '2cc3d22840e086105ad73443da2cacb8'; // #LongFast
|
||||||
|
const text = `${sender}: ${message}`;
|
||||||
|
const buf = Buffer.alloc(5 + text.length + 1);
|
||||||
|
buf.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
||||||
|
buf[4] = 0;
|
||||||
|
buf.write(text + '\0', 5, 'utf8');
|
||||||
|
|
||||||
|
const padded = Buffer.alloc(Math.ceil(buf.length / 16) * 16);
|
||||||
|
buf.copy(padded);
|
||||||
|
|
||||||
|
const keyWords = CryptoJS.enc.Hex.parse(channelKey);
|
||||||
|
const plaintextWords = CryptoJS.enc.Hex.parse(padded.toString('hex'));
|
||||||
|
const encrypted = CryptoJS.AES.encrypt(plaintextWords, keyWords, {
|
||||||
|
mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding
|
||||||
|
});
|
||||||
|
const cipherHex = encrypted.ciphertext.toString(CryptoJS.enc.Hex);
|
||||||
|
|
||||||
|
const channelSecret = Buffer.alloc(32);
|
||||||
|
Buffer.from(channelKey, 'hex').copy(channelSecret);
|
||||||
|
const mac = CryptoJS.HmacSHA256(
|
||||||
|
CryptoJS.enc.Hex.parse(cipherHex),
|
||||||
|
CryptoJS.enc.Hex.parse(channelSecret.toString('hex'))
|
||||||
|
);
|
||||||
|
const macHex = mac.toString(CryptoJS.enc.Hex).substring(0, 4);
|
||||||
|
|
||||||
|
const chHash = ChannelCrypto.calculateChannelHash('#LongFast');
|
||||||
|
const grpPayload = Buffer.from(
|
||||||
|
chHash.toString(16).padStart(2, '0') + macHex + cipherHex, 'hex'
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = encodeHeader(1, 0x05, 0);
|
||||||
|
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||||
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, grpPayload]);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to unencrypted if crypto libs unavailable
|
||||||
|
return buildGrpTxt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAck() {
|
||||||
|
const payload = randomBytes(18);
|
||||||
|
const header = encodeHeader(2, 0x03, 0);
|
||||||
|
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||||
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTxtMsg() {
|
||||||
|
const payload = Buffer.concat([randomBytes(6), randomBytes(6), randomBytes(4), randomBytes(20)]);
|
||||||
|
const header = encodeHeader(2, 0x02, 0);
|
||||||
|
const { pathByte, hops } = buildPath(randInt(0, 2));
|
||||||
|
return Buffer.concat([Buffer.from([header, pathByte]), hops, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeContentHash(hex) {
|
||||||
|
return crypto.createHash('sha256').update(hex.toUpperCase()).digest('hex').substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
const r = await fetch(`${BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: r.status, data: await r.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Seeding test data into ${BASE}...`);
|
||||||
|
|
||||||
|
const packets = [];
|
||||||
|
|
||||||
|
// 1. ADVERTs for each node (creates nodes with location for map)
|
||||||
|
const roles = ['repeater', 'repeater', 'room', 'companion', 'repeater', 'companion', 'sensor', 'repeater'];
|
||||||
|
for (let i = 0; i < NODE_NAMES.length; i++) {
|
||||||
|
const obs = pick(OBSERVERS);
|
||||||
|
const hex = buildAdvert(NODE_NAMES[i], roles[i]).toString('hex').toUpperCase();
|
||||||
|
const hash = computeContentHash(hex);
|
||||||
|
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: 5.0, rssi: -80 });
|
||||||
|
// Send same advert from multiple observers for compare page
|
||||||
|
for (const otherObs of OBSERVERS) {
|
||||||
|
if (otherObs.id !== obs.id) {
|
||||||
|
packets.push({ hex, observer: otherObs.id, region: otherObs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Encrypted GRP_TXT packets (creates channel messages for channels page)
|
||||||
|
const chatMessages = [
|
||||||
|
['Alice', 'Hello everyone!'], ['Bob', 'Hey Alice!'], ['Charlie', 'Good morning'],
|
||||||
|
['Alice', 'How is the mesh today?'], ['Bob', 'Looking great, 8 nodes online'],
|
||||||
|
['Charlie', 'I just set up a new repeater'], ['Alice', 'Nice! Where is it?'],
|
||||||
|
['Bob', 'Signal looks strong from here'], ['Charlie', 'On top of the hill'],
|
||||||
|
['Alice', 'Perfect location!'],
|
||||||
|
];
|
||||||
|
for (const [sender, message] of chatMessages) {
|
||||||
|
const obs = pick(OBSERVERS);
|
||||||
|
const hex = buildEncryptedGrpTxt(sender, message).toString('hex').toUpperCase();
|
||||||
|
const hash = computeContentHash(hex);
|
||||||
|
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Unencrypted GRP_TXT packets (won't create channel entries but add packet variety)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const obs = pick(OBSERVERS);
|
||||||
|
const hex = buildGrpTxt(randInt(0, 3)).toString('hex').toUpperCase();
|
||||||
|
const hash = computeContentHash(hex);
|
||||||
|
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ACK packets
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const obs = pick(OBSERVERS);
|
||||||
|
const hex = buildAck().toString('hex').toUpperCase();
|
||||||
|
const hash = computeContentHash(hex);
|
||||||
|
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. TXT_MSG packets
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const obs = pick(OBSERVERS);
|
||||||
|
const hex = buildTxtMsg().toString('hex').toUpperCase();
|
||||||
|
const hash = computeContentHash(hex);
|
||||||
|
packets.push({ hex, observer: obs.id, region: obs.iata, hash, snr: rand(-2, 10), rssi: rand(-110, -60) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Extra packets with shared hashes (for trace/compare)
|
||||||
|
for (let t = 0; t < 5; t++) {
|
||||||
|
const hex = buildGrpTxt(0).toString('hex').toUpperCase();
|
||||||
|
const traceHash = computeContentHash(hex);
|
||||||
|
for (const obs of OBSERVERS) {
|
||||||
|
packets.push({ hex, observer: obs.id, region: obs.iata, hash: traceHash, snr: 5, rssi: -80 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Injecting ${packets.length} packets...`);
|
||||||
|
let ok = 0, fail = 0;
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const r = await post('/api/packets', pkt);
|
||||||
|
if (r.status === 200) ok++;
|
||||||
|
else { fail++; if (fail <= 3) console.error(' Inject fail:', r.data); }
|
||||||
|
}
|
||||||
|
console.log(`Done: ${ok} ok, ${fail} fail`);
|
||||||
|
|
||||||
|
if (fail > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1); });
|
||||||
Reference in New Issue
Block a user