mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 15:55:49 +00:00
Compare commits
4 Commits
fix/ci-she
...
fix/live-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cee2da2e1 | ||
|
|
b7210e4e31 | ||
|
|
ab6fbec158 | ||
|
|
39c4c3e16e |
52
.github/workflows/deploy.yml
vendored
52
.github/workflows/deploy.yml
vendored
@@ -20,10 +20,6 @@ concurrency:
|
||||
group: deploy-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
@@ -45,10 +41,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go 1.22
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: |
|
||||
@@ -126,16 +122,9 @@ jobs:
|
||||
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer ${{ github.token }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
|
||||
|
||||
- name: Upload Go coverage badges
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/go-*.json
|
||||
@@ -147,15 +136,15 @@ jobs:
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
node-test:
|
||||
name: "🧪 Node.js Tests"
|
||||
runs-on: [self-hosted, Linux]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -274,16 +263,9 @@ jobs:
|
||||
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
|
||||
- name: Cancel workflow on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer ${{ github.token }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
|
||||
|
||||
- name: Upload Node.js test badges
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-badges
|
||||
path: .badges/
|
||||
@@ -296,14 +278,14 @@ jobs:
|
||||
build:
|
||||
name: "🏗️ Build Docker Image"
|
||||
if: github.event_name == 'push'
|
||||
needs: [go-test, node-test]
|
||||
runs-on: [self-hosted, Linux]
|
||||
needs: [go-test]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 22
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -322,10 +304,10 @@ jobs:
|
||||
name: "🚀 Deploy Staging"
|
||||
if: github.event_name == 'push'
|
||||
needs: [build]
|
||||
runs-on: [self-hosted, Linux]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Start staging on port 82
|
||||
run: |
|
||||
@@ -367,21 +349,21 @@ jobs:
|
||||
name: "📝 Publish Badges & Summary"
|
||||
if: github.event_name == 'push'
|
||||
needs: [deploy]
|
||||
runs-on: [self-hosted, Linux]
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Go coverage badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: go-badges
|
||||
path: .badges/
|
||||
|
||||
- name: Download Node.js test badges
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node-badges
|
||||
path: .badges/
|
||||
|
||||
@@ -72,8 +72,8 @@ type Header struct {
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
NextHop string `json:"nextHop"`
|
||||
LastHop string `json:"lastHop"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
@@ -92,8 +92,6 @@ type AdvertFlags struct {
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
@@ -113,8 +111,6 @@ type Payload struct {
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Feat1 *int `json:"feat1,omitempty"`
|
||||
Feat2 *int `json:"feat2,omitempty"`
|
||||
BatteryMv *int `json:"battery_mv,omitempty"`
|
||||
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||
ChannelHash int `json:"channelHash,omitempty"`
|
||||
@@ -127,8 +123,6 @@ type Payload struct {
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -205,13 +199,14 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
if len(buf) < 6 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
ExtraHash: hex.EncodeToString(buf[2:6]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,8 +231,6 @@ func decodeAdvert(buf []byte) Payload {
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
@@ -246,8 +239,6 @@ func decodeAdvert(buf []byte) Payload {
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
@@ -261,16 +252,6 @@ func decodeAdvert(buf []byte) Payload {
|
||||
p.Lon = &lon
|
||||
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 {
|
||||
// Find null terminator to separate name from trailing telemetry bytes
|
||||
nameEnd := len(appdata)
|
||||
@@ -488,22 +469,15 @@ func decodePathPayload(buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 9 {
|
||||
if len(buf) < 12 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
return Payload{
|
||||
Type: "TRACE",
|
||||
DestHash: hex.EncodeToString(buf[5:11]),
|
||||
SrcHash: hex.EncodeToString(buf[11:12]),
|
||||
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
|
||||
@@ -546,7 +520,8 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
pathByte := buf[1]
|
||||
offset := 2
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
@@ -554,18 +529,12 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : 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)
|
||||
offset += bytesConsumed
|
||||
|
||||
@@ -593,24 +562,16 @@ func ComputeContentHash(rawHex string) string {
|
||||
return rawHex
|
||||
}
|
||||
|
||||
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++
|
||||
pathByte := buf[1]
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
payloadStart := offset + pathBytes
|
||||
headerByte := buf[0]
|
||||
payloadStart := 2 + pathBytes
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
payloadStart += 4
|
||||
}
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
|
||||
@@ -129,8 +129,7 @@ func TestDecodePath3ByteHashes(t *testing.T) {
|
||||
|
||||
func TestTransportCodes(t *testing.T) {
|
||||
// Route type 0 (TRANSPORT_FLOOD) should have transport codes
|
||||
// Firmware order: header + transport_codes(4) + path_len + path + payload
|
||||
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
|
||||
hex := "1400" + "AABB" + "CCDD" + "1A" + strings.Repeat("00", 10)
|
||||
pkt, err := DecodePacket(hex, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -141,11 +140,11 @@ func TestTransportCodes(t *testing.T) {
|
||||
if pkt.TransportCodes == nil {
|
||||
t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD")
|
||||
}
|
||||
if pkt.TransportCodes.Code1 != "AABB" {
|
||||
t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1)
|
||||
if pkt.TransportCodes.NextHop != "AABB" {
|
||||
t.Errorf("nextHop=%s, want AABB", pkt.TransportCodes.NextHop)
|
||||
}
|
||||
if pkt.TransportCodes.Code2 != "CCDD" {
|
||||
t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2)
|
||||
if pkt.TransportCodes.LastHop != "CCDD" {
|
||||
t.Errorf("lastHop=%s, want CCDD", pkt.TransportCodes.LastHop)
|
||||
}
|
||||
|
||||
// Route type 1 (FLOOD) should NOT have transport codes
|
||||
@@ -538,11 +537,10 @@ func TestDecodeTraceShort(t *testing.T) {
|
||||
|
||||
func TestDecodeTraceValid(t *testing.T) {
|
||||
buf := make([]byte, 16)
|
||||
// tag(4) + authCode(4) + flags(1) + pathData
|
||||
binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1
|
||||
binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode
|
||||
buf[8] = 0x02 // flags
|
||||
buf[9] = 0xAA // path data
|
||||
buf[0] = 0x00
|
||||
buf[1] = 0x01 // tag LE uint32 = 1
|
||||
buf[5] = 0xAA // destHash start
|
||||
buf[11] = 0xBB
|
||||
p := decodeTrace(buf)
|
||||
if p.Error != "" {
|
||||
t.Errorf("unexpected error: %s", p.Error)
|
||||
@@ -550,18 +548,9 @@ func TestDecodeTraceValid(t *testing.T) {
|
||||
if p.Tag != 1 {
|
||||
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" {
|
||||
t.Errorf("type=%s, want TRACE", p.Type)
|
||||
}
|
||||
if p.PathData == "" {
|
||||
t.Error("pathData should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAdvertShort(t *testing.T) {
|
||||
@@ -844,9 +833,10 @@ func TestComputeContentHashShortHex(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeContentHashTransportRoute(t *testing.T) {
|
||||
// Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops)
|
||||
// header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00
|
||||
hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10)
|
||||
// Route type 0 (TRANSPORT_FLOOD) with no path hops + 4 transport code bytes
|
||||
// header=0x14 (TRANSPORT_FLOOD, ADVERT), path=0x00 (0 hops)
|
||||
// transport codes = 4 bytes, then payload
|
||||
hex := "1400" + "AABBCCDD" + strings.Repeat("EE", 10)
|
||||
hash := ComputeContentHash(hex)
|
||||
if len(hash) != 16 {
|
||||
t.Errorf("hash length=%d, want 16", len(hash))
|
||||
@@ -880,10 +870,12 @@ func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) {
|
||||
|
||||
func TestComputeContentHashTransportBeyondBuffer(t *testing.T) {
|
||||
// Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes
|
||||
// header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash)
|
||||
// offset=1+4+1+2=8, buffer needs to be >= 8
|
||||
hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
||||
// total buffer too short for transport codes + path
|
||||
// header=0x00, pathByte=0x02 (2 hops, 1-byte hash), then only 2 more bytes
|
||||
// payloadStart = 2 + 2 + 4(transport) = 8, but buffer only 6 bytes
|
||||
hex := "0002" + "AABB" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
|
||||
hash := ComputeContentHash(hex)
|
||||
// payloadStart = 2 + 2 + 4 = 8, buffer is 10 bytes → should work
|
||||
if len(hash) != 16 {
|
||||
t.Errorf("hash length=%d, want 16", len(hash))
|
||||
}
|
||||
@@ -921,8 +913,8 @@ func TestDecodePacketWithNewlines(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodePacketTransportRouteTooShort(t *testing.T) {
|
||||
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes
|
||||
_, err := DecodePacket("1400", nil)
|
||||
// TRANSPORT_FLOOD (route=0) but only 3 bytes total → too short for transport codes
|
||||
_, err := DecodePacket("140011", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for transport route with too-short buffer")
|
||||
}
|
||||
@@ -939,19 +931,16 @@ func TestDecodeAckShort(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDecodeAckValid(t *testing.T) {
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD}
|
||||
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||
p := decodeAck(buf)
|
||||
if p.Error != "" {
|
||||
t.Errorf("unexpected error: %s", p.Error)
|
||||
}
|
||||
if p.ExtraHash != "ddccbbaa" {
|
||||
t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash)
|
||||
if p.DestHash != "aa" {
|
||||
t.Errorf("destHash=%s, want aa", p.DestHash)
|
||||
}
|
||||
if p.DestHash != "" {
|
||||
t.Errorf("destHash should be empty, got %s", p.DestHash)
|
||||
}
|
||||
if p.SrcHash != "" {
|
||||
t.Errorf("srcHash should be empty, got %s", p.SrcHash)
|
||||
if p.ExtraHash != "ccddeeff" {
|
||||
t.Errorf("extraHash=%s, want ccddeeff", p.ExtraHash)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ type Header struct {
|
||||
|
||||
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
|
||||
type TransportCodes struct {
|
||||
Code1 string `json:"code1"`
|
||||
Code2 string `json:"code2"`
|
||||
NextHop string `json:"nextHop"`
|
||||
LastHop string `json:"lastHop"`
|
||||
}
|
||||
|
||||
// Path holds decoded path/hop information.
|
||||
@@ -74,8 +74,6 @@ type AdvertFlags struct {
|
||||
Room bool `json:"room"`
|
||||
Sensor bool `json:"sensor"`
|
||||
HasLocation bool `json:"hasLocation"`
|
||||
HasFeat1 bool `json:"hasFeat1"`
|
||||
HasFeat2 bool `json:"hasFeat2"`
|
||||
HasName bool `json:"hasName"`
|
||||
}
|
||||
|
||||
@@ -99,8 +97,6 @@ type Payload struct {
|
||||
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
|
||||
PathData string `json:"pathData,omitempty"`
|
||||
Tag uint32 `json:"tag,omitempty"`
|
||||
AuthCode uint32 `json:"authCode,omitempty"`
|
||||
TraceFlags *int `json:"traceFlags,omitempty"`
|
||||
RawHex string `json:"raw,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -177,13 +173,14 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeAck(buf []byte) Payload {
|
||||
if len(buf) < 4 {
|
||||
if len(buf) < 6 {
|
||||
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
checksum := binary.LittleEndian.Uint32(buf[0:4])
|
||||
return Payload{
|
||||
Type: "ACK",
|
||||
ExtraHash: fmt.Sprintf("%08x", checksum),
|
||||
DestHash: hex.EncodeToString(buf[0:1]),
|
||||
SrcHash: hex.EncodeToString(buf[1:2]),
|
||||
ExtraHash: hex.EncodeToString(buf[2:6]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,8 +205,6 @@ func decodeAdvert(buf []byte) Payload {
|
||||
if len(appdata) > 0 {
|
||||
flags := appdata[0]
|
||||
advType := int(flags & 0x0F)
|
||||
hasFeat1 := flags&0x20 != 0
|
||||
hasFeat2 := flags&0x40 != 0
|
||||
p.Flags = &AdvertFlags{
|
||||
Raw: int(flags),
|
||||
Type: advType,
|
||||
@@ -218,8 +213,6 @@ func decodeAdvert(buf []byte) Payload {
|
||||
Room: advType == 3,
|
||||
Sensor: advType == 4,
|
||||
HasLocation: flags&0x10 != 0,
|
||||
HasFeat1: hasFeat1,
|
||||
HasFeat2: hasFeat2,
|
||||
HasName: flags&0x80 != 0,
|
||||
}
|
||||
|
||||
@@ -233,12 +226,6 @@ func decodeAdvert(buf []byte) Payload {
|
||||
p.Lon = &lon
|
||||
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 {
|
||||
name := string(appdata[off:])
|
||||
name = strings.TrimRight(name, "\x00")
|
||||
@@ -289,22 +276,15 @@ func decodePathPayload(buf []byte) Payload {
|
||||
}
|
||||
|
||||
func decodeTrace(buf []byte) Payload {
|
||||
if len(buf) < 9 {
|
||||
if len(buf) < 12 {
|
||||
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
|
||||
}
|
||||
tag := binary.LittleEndian.Uint32(buf[0:4])
|
||||
authCode := binary.LittleEndian.Uint32(buf[4:8])
|
||||
flags := int(buf[8])
|
||||
p := Payload{
|
||||
Type: "TRACE",
|
||||
Tag: tag,
|
||||
AuthCode: authCode,
|
||||
TraceFlags: &flags,
|
||||
return Payload{
|
||||
Type: "TRACE",
|
||||
DestHash: hex.EncodeToString(buf[5:11]),
|
||||
SrcHash: hex.EncodeToString(buf[11:12]),
|
||||
Tag: binary.LittleEndian.Uint32(buf[1:5]),
|
||||
}
|
||||
if len(buf) > 9 {
|
||||
p.PathData = hex.EncodeToString(buf[9:])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func decodePayload(payloadType int, buf []byte) Payload {
|
||||
@@ -347,7 +327,8 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
}
|
||||
|
||||
header := decodeHeader(buf[0])
|
||||
offset := 1
|
||||
pathByte := buf[1]
|
||||
offset := 2
|
||||
|
||||
var tc *TransportCodes
|
||||
if isTransportRoute(header.RouteType) {
|
||||
@@ -355,18 +336,12 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
|
||||
return nil, fmt.Errorf("packet too short for transport codes")
|
||||
}
|
||||
tc = &TransportCodes{
|
||||
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
|
||||
NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
|
||||
LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : 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)
|
||||
offset += bytesConsumed
|
||||
|
||||
@@ -392,24 +367,16 @@ func ComputeContentHash(rawHex string) string {
|
||||
return rawHex
|
||||
}
|
||||
|
||||
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++
|
||||
pathByte := buf[1]
|
||||
hashSize := int((pathByte>>6)&0x3) + 1
|
||||
hashCount := int(pathByte & 0x3F)
|
||||
pathBytes := hashSize * hashCount
|
||||
|
||||
payloadStart := offset + pathBytes
|
||||
headerByte := buf[0]
|
||||
payloadStart := 2 + pathBytes
|
||||
if isTransportRoute(int(headerByte & 0x03)) {
|
||||
payloadStart += 4
|
||||
}
|
||||
if payloadStart > len(buf) {
|
||||
if len(rawHex) >= 16 {
|
||||
return rawHex[:16]
|
||||
|
||||
56
decoder.js
56
decoder.js
@@ -2,8 +2,8 @@
|
||||
* MeshCore Packet Decoder
|
||||
* Custom implementation — does NOT use meshcore-decoder library (known path_length bug).
|
||||
*
|
||||
* Packet layout (per firmware docs/packet_format.md):
|
||||
* [header(1)] [transportCodes?(4)] [pathLength(1)] [path hops] [payload...]
|
||||
* Packet layout:
|
||||
* [header(1)] [pathLength(1)] [transportCodes?] [path hops] [payload...]
|
||||
*
|
||||
* Header byte (LSB first):
|
||||
* bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT)
|
||||
@@ -42,7 +42,7 @@ const PAYLOAD_TYPES = {
|
||||
0x0F: 'RAW_CUSTOM',
|
||||
};
|
||||
|
||||
// Route types that carry transport codes (2x uint16_t, 4 bytes total)
|
||||
// Route types that carry transport codes (nextHop + lastHop, 2 bytes each)
|
||||
const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT
|
||||
|
||||
// --- Header parsing ---
|
||||
@@ -94,11 +94,13 @@ function decodeEncryptedPayload(buf) {
|
||||
};
|
||||
}
|
||||
|
||||
/** ACK: checksum(4) — CRC of message timestamp + text + sender pubkey (per Mesh.cpp createAck) */
|
||||
/** ACK: dest(1) + src(1) + ack_hash(4) (per Mesh.cpp) */
|
||||
function decodeAck(buf) {
|
||||
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
||||
if (buf.length < 6) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
ackChecksum: buf.subarray(0, 4).toString('hex'),
|
||||
destHash: buf.subarray(0, 1).toString('hex'),
|
||||
srcHash: buf.subarray(1, 2).toString('hex'),
|
||||
extraHash: buf.subarray(2, 6).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,8 +125,6 @@ function decodeAdvert(buf) {
|
||||
room: advType === 3,
|
||||
sensor: advType === 4,
|
||||
hasLocation: !!(flags & 0x10),
|
||||
hasFeat1: !!(flags & 0x20),
|
||||
hasFeat2: !!(flags & 0x40),
|
||||
hasName: !!(flags & 0x80),
|
||||
};
|
||||
|
||||
@@ -134,14 +134,6 @@ function decodeAdvert(buf) {
|
||||
result.lon = appdata.readInt32LE(off + 4) / 1e6;
|
||||
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) {
|
||||
// Find null terminator to separate name from trailing telemetry bytes
|
||||
let nameEnd = appdata.length;
|
||||
@@ -239,7 +231,7 @@ function decodeGrpTxt(buf, channelKeys) {
|
||||
return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData };
|
||||
}
|
||||
|
||||
/** ANON_REQ: dest(1) + ephemeral_pubkey(32) + MAC(2) + encrypted */
|
||||
/** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */
|
||||
function decodeAnonReq(buf) {
|
||||
if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
@@ -250,7 +242,7 @@ function decodeAnonReq(buf) {
|
||||
};
|
||||
}
|
||||
|
||||
/** PATH: dest(1) + src(1) + MAC(2) + path_data */
|
||||
/** PATH: dest(6) + src(6) + MAC(4) + path_data */
|
||||
function decodePath_payload(buf) {
|
||||
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
@@ -261,14 +253,14 @@ function decodePath_payload(buf) {
|
||||
};
|
||||
}
|
||||
|
||||
/** TRACE: tag(4) + authCode(4) + flags(1) + pathData (per Mesh.cpp onRecvPacket TRACE) */
|
||||
/** TRACE: flags(1) + tag(4) + dest(6) + src(1) */
|
||||
function decodeTrace(buf) {
|
||||
if (buf.length < 9) return { error: 'too short', raw: buf.toString('hex') };
|
||||
if (buf.length < 12) return { error: 'too short', raw: buf.toString('hex') };
|
||||
return {
|
||||
tag: buf.readUInt32LE(0),
|
||||
authCode: buf.subarray(4, 8).toString('hex'),
|
||||
flags: buf[8],
|
||||
pathData: buf.subarray(9).toString('hex'),
|
||||
flags: buf[0],
|
||||
tag: buf.readUInt32LE(1),
|
||||
destHash: buf.subarray(5, 11).toString('hex'),
|
||||
srcHash: buf.subarray(11, 12).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -297,22 +289,20 @@ function decodePacket(hexString, channelKeys) {
|
||||
if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)');
|
||||
|
||||
const header = decodeHeader(buf[0]);
|
||||
let offset = 1;
|
||||
const pathByte = buf[1];
|
||||
let offset = 2;
|
||||
|
||||
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT — BEFORE path_length per spec
|
||||
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT
|
||||
let transportCodes = null;
|
||||
if (TRANSPORT_ROUTES.has(header.routeType)) {
|
||||
if (buf.length < offset + 4) throw new Error('Packet too short for transport codes');
|
||||
transportCodes = {
|
||||
code1: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
|
||||
code2: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
|
||||
nextHop: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
|
||||
lastHop: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
|
||||
};
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
// Path length byte — AFTER transport codes per spec
|
||||
const pathByte = buf[offset++];
|
||||
|
||||
// Path
|
||||
const path = decodePath(pathByte, buf, offset);
|
||||
offset += path.bytesConsumed;
|
||||
@@ -396,7 +386,7 @@ module.exports = { decodePacket, validateAdvert, hasNonPrintableChars, ROUTE_TYP
|
||||
|
||||
// --- Tests ---
|
||||
if (require.main === module) {
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ===');
|
||||
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
|
||||
const pkt1 = decodePacket(
|
||||
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
|
||||
);
|
||||
@@ -412,7 +402,7 @@ if (require.main === module) {
|
||||
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
|
||||
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
|
||||
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
|
||||
assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"');
|
||||
assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
|
||||
console.log('✅ Test 1 passed\n');
|
||||
|
||||
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<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:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774786038">
|
||||
<link rel="stylesheet" href="home.css?v=1774786038">
|
||||
<link rel="stylesheet" href="live.css?v=1774786038">
|
||||
<link rel="stylesheet" href="style.css?v=1774731523">
|
||||
<link rel="stylesheet" href="home.css?v=1774731523">
|
||||
<link rel="stylesheet" href="live.css?v=1774731523">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -81,29 +81,29 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774786038"></script>
|
||||
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774786038"></script>
|
||||
<script src="hop-resolver.js?v=1774786038"></script>
|
||||
<script src="hop-display.js?v=1774786038"></script>
|
||||
<script src="app.js?v=1774786038"></script>
|
||||
<script src="home.js?v=1774786038"></script>
|
||||
<script src="packet-filter.js?v=1774786038"></script>
|
||||
<script src="packets.js?v=1774786038"></script>
|
||||
<script src="map.js?v=1774786038" 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=1774786038" 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=1774786038" 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=1774786038" 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=1774786038" 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=1774786038" 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=1774786038" 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=1774786038" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774731523"></script>
|
||||
<script src="customize.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="region-filter.js?v=1774731523"></script>
|
||||
<script src="hop-resolver.js?v=1774731523"></script>
|
||||
<script src="hop-display.js?v=1774731523"></script>
|
||||
<script src="app.js?v=1774731523"></script>
|
||||
<script src="home.js?v=1774731523"></script>
|
||||
<script src="packet-filter.js?v=1774731523"></script>
|
||||
<script src="packets.js?v=1774731523"></script>
|
||||
<script src="map.js?v=1774731523" 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="nodes.js?v=1774731523" 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="analytics.js?v=1774731523" 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-v1-constellation.js?v=1774731523" 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-lab.js?v=1774731523" 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="observers.js?v=1774731523" 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="compare.js?v=1774731523" 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="perf.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1512,12 +1512,14 @@
|
||||
rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', '');
|
||||
if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, '');
|
||||
} else if (decoded.type === 'ACK') {
|
||||
rows += fieldRow(off, 'Checksum (4B)', decoded.ackChecksum || '', '');
|
||||
rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
|
||||
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
|
||||
rows += fieldRow(off + 12, 'Extra (6B)', decoded.extraHash || '', '');
|
||||
} else if (decoded.destHash !== undefined) {
|
||||
rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', '');
|
||||
rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', '');
|
||||
rows += fieldRow(off + 2, 'MAC (2B)', decoded.mac || '', '');
|
||||
rows += fieldRow(off + 4, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
|
||||
rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
|
||||
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
|
||||
rows += fieldRow(off + 12, 'MAC (4B)', decoded.mac || '', '');
|
||||
rows += fieldRow(off + 16, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
|
||||
} else {
|
||||
rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), '');
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@
|
||||
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.numGC}</div><div class="perf-label">GC Collections</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).toFixed(1)}ms</div><div class="perf-label">Last GC Pause</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).toFixed(1)}MB</div><div class="perf-label">Heap Sys</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).toFixed(1)}MB</div><div class="perf-label">Heap Idle</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">${gr.lastPauseMs}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.heapSysMB}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.heapIdleMB}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">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
|
||||
</div>`;
|
||||
|
||||
@@ -155,7 +155,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
/* === Nav Stats === */
|
||||
.nav-stats {
|
||||
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
|
||||
font-family: var(--mono); margin-right: 4px; white-space: nowrap;
|
||||
font-family: var(--mono); margin-right: 4px;
|
||||
}
|
||||
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
|
||||
.nav-stats .stat-val.updated { color: var(--accent); }
|
||||
|
||||
13
server.js
13
server.js
@@ -207,13 +207,6 @@ class TTLCache {
|
||||
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() {
|
||||
if (this._debounceTimer) return;
|
||||
this._debounceTimer = setTimeout(() => {
|
||||
@@ -417,7 +410,7 @@ app.get('/api/perf', (req, res) => {
|
||||
avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0,
|
||||
endpoints: Object.fromEntries(sorted),
|
||||
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.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + 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.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0 },
|
||||
packetStore: pktStore.getStats(),
|
||||
sqlite: (() => {
|
||||
try {
|
||||
@@ -526,7 +519,7 @@ app.get('/api/health', (req, res) => {
|
||||
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,
|
||||
hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0,
|
||||
},
|
||||
websocket: {
|
||||
clients: wsClients,
|
||||
@@ -730,7 +723,7 @@ for (const source of mqttSources) {
|
||||
// Invalidate this node's caches on advert
|
||||
cache.invalidate('node:' + p.pubKey);
|
||||
cache.invalidate('health:' + p.pubKey);
|
||||
cache.debouncedInvalidateBulkHealth();
|
||||
cache.invalidate('bulk-health');
|
||||
|
||||
// Cross-reference: if this node's pubkey matches an existing observer, backfill observer name
|
||||
if (p.name && p.pubKey) {
|
||||
|
||||
@@ -122,14 +122,13 @@ console.log('── Spec Tests: Transport Codes ──');
|
||||
|
||||
{
|
||||
// Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes
|
||||
// Route type 0: header=0x14 = payloadType 5 (GRP_TXT), routeType 0 (TRANSPORT_FLOOD)
|
||||
// Format: header(1) + transportCodes(4) + pathByte(1) + payload
|
||||
const hex = '14' + 'AABB' + 'CCDD' + '00' + '1A' + '00'.repeat(10); // transport codes + pathByte + GRP_TXT payload
|
||||
// Route type 0: header byte = 0bPPPPPP00, e.g. 0x14 = payloadType 5 (GRP_TXT), routeType 0
|
||||
const hex = '1400' + 'AABB' + 'CCDD' + '1A' + '00'.repeat(10); // transport codes + GRP_TXT payload
|
||||
const p = decodePacket(hex);
|
||||
assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)');
|
||||
assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD');
|
||||
assertEq(p.transportCodes.code1, 'AABB', 'transport: code1');
|
||||
assertEq(p.transportCodes.code2, 'CCDD', 'transport: code2');
|
||||
assertEq(p.transportCodes.nextHop, 'AABB', 'transport: nextHop');
|
||||
assertEq(p.transportCodes.lastHop, 'CCDD', 'transport: lastHop');
|
||||
}
|
||||
|
||||
{
|
||||
@@ -258,13 +257,13 @@ console.log('── Spec Tests: Advert Payload ──');
|
||||
|
||||
console.log('── Spec Tests: Encrypted Payload Format ──');
|
||||
|
||||
// Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher — decoder matches this.
|
||||
// NOTE: Spec says v1 encrypted payloads have dest(1) + src(1) + MAC(2) + ciphertext
|
||||
// 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.
|
||||
{
|
||||
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');
|
||||
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');
|
||||
}
|
||||
|
||||
console.log('── Spec Tests: validateAdvert ──');
|
||||
|
||||
@@ -28,22 +28,22 @@ test('FLOOD + ADVERT = 0x11', () => {
|
||||
});
|
||||
|
||||
test('TRANSPORT_FLOOD = routeType 0', () => {
|
||||
// header=0x00 (TRANSPORT_FLOOD + REQ), transportCodes=AABB+CCDD, pathByte=0x00, payload
|
||||
const hex = '00' + 'AABB' + 'CCDD' + '00' + '00'.repeat(16);
|
||||
// 0x00 = TRANSPORT_FLOOD + REQ(0), needs transport codes + 16 byte payload
|
||||
const hex = '0000' + 'AABB' + 'CCDD' + '00'.repeat(16);
|
||||
const p = decodePacket(hex);
|
||||
assert.strictEqual(p.header.routeType, 0);
|
||||
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD');
|
||||
assert.notStrictEqual(p.transportCodes, null);
|
||||
assert.strictEqual(p.transportCodes.code1, 'AABB');
|
||||
assert.strictEqual(p.transportCodes.code2, 'CCDD');
|
||||
assert.strictEqual(p.transportCodes.nextHop, 'AABB');
|
||||
assert.strictEqual(p.transportCodes.lastHop, 'CCDD');
|
||||
});
|
||||
|
||||
test('TRANSPORT_DIRECT = routeType 3', () => {
|
||||
const hex = '03' + '1122' + '3344' + '00' + '00'.repeat(16);
|
||||
const hex = '0300' + '1122' + '3344' + '00'.repeat(16);
|
||||
const p = decodePacket(hex);
|
||||
assert.strictEqual(p.header.routeType, 3);
|
||||
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT');
|
||||
assert.strictEqual(p.transportCodes.code1, '1122');
|
||||
assert.strictEqual(p.transportCodes.nextHop, '1122');
|
||||
});
|
||||
|
||||
test('DIRECT = routeType 2, no transport codes', () => {
|
||||
@@ -358,7 +358,9 @@ test('ACK decode', () => {
|
||||
const hex = '0D00' + '00'.repeat(18);
|
||||
const p = decodePacket(hex);
|
||||
assert.strictEqual(p.payload.type, 'ACK');
|
||||
assert(p.payload.ackChecksum);
|
||||
assert(p.payload.destHash);
|
||||
assert(p.payload.srcHash);
|
||||
assert(p.payload.extraHash);
|
||||
});
|
||||
|
||||
test('ACK too short', () => {
|
||||
@@ -422,9 +424,9 @@ test('TRACE decode', () => {
|
||||
const hex = '2500' + '00'.repeat(12);
|
||||
const p = decodePacket(hex);
|
||||
assert.strictEqual(p.payload.type, 'TRACE');
|
||||
assert(p.payload.tag !== undefined);
|
||||
assert(p.payload.authCode !== undefined);
|
||||
assert.strictEqual(p.payload.flags, 0);
|
||||
assert(p.payload.tag !== undefined);
|
||||
assert(p.payload.destHash);
|
||||
});
|
||||
|
||||
test('TRACE too short', () => {
|
||||
@@ -458,18 +460,16 @@ test('Transport route too short throws', () => {
|
||||
assert.throws(() => decodePacket('0000'), /too short for transport/);
|
||||
});
|
||||
|
||||
test('Corrupt packet #183 — TRANSPORT_DIRECT with correct field order', () => {
|
||||
test('Corrupt packet #183 — path overflow capped to buffer', () => {
|
||||
const hex = 'BBAD6797EC8751D500BF95A1A776EF580E665BCBF6A0BBE03B5E730707C53489B8C728FD3FB902397197E1263CEC21E52465362243685DBBAD6797EC8751C90A75D9FD8213155D';
|
||||
const p = decodePacket(hex);
|
||||
assert.strictEqual(p.header.routeType, 3, 'routeType should be TRANSPORT_DIRECT');
|
||||
assert.strictEqual(p.header.payloadTypeName, 'UNKNOWN');
|
||||
// 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
|
||||
// pathByte 0xAD claims 45 hops × 3 bytes = 135, but only 65 bytes available
|
||||
assert.strictEqual(p.path.hashSize, 3);
|
||||
assert.strictEqual(p.path.hashCount, 7);
|
||||
assert.strictEqual(p.path.hops.length, 7);
|
||||
assert.strictEqual(p.path.hashCount, 21, 'hashCount capped to fit buffer');
|
||||
assert.strictEqual(p.path.hops.length, 21);
|
||||
assert.strictEqual(p.path.truncated, true);
|
||||
// No empty strings in hops
|
||||
assert(p.path.hops.every(h => h.length > 0), 'no empty hops');
|
||||
});
|
||||
|
||||
@@ -1254,24 +1254,6 @@ seedTestData();
|
||||
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 ──
|
||||
console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user