diff --git a/ingest/cmd/meshcoreingest/main.go b/ingest/cmd/meshcoreingest/main.go index f68ba46..755e784 100644 --- a/ingest/cmd/meshcoreingest/main.go +++ b/ingest/cmd/meshcoreingest/main.go @@ -177,25 +177,51 @@ func parseMeshCoreRawMessage(payload []byte) (origin string, originPubkey []byte return cleanOrigin, originPubkeyBytes, meshTimestamp, packet, nil } +// flexString unmarshals a JSON value that may be encoded as either a string or +// a number into a string. Depending on gateway firmware, the numeric metric +// fields below — SNR, len, etc. — arrive as JSON numbers (including negative +// and fractional values) rather than strings; without this, a single numeric +// field would fail the whole packet's unmarshal and drop the message, including +// the raw packet bytes we actually need. +type flexString string + +func (f *flexString) UnmarshalJSON(data []byte) error { + if len(data) == 0 || string(data) == "null" { + *f = "" + return nil + } + if data[0] == '"' { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *f = flexString(s) + return nil + } + // Non-string (number, bool): keep the raw JSON token as its text form. + *f = flexString(data) + return nil +} + func parseMeshCorePacketsMessage(payload []byte) (origin string, originPubkey []byte, meshTimestamp time.Time, packet []byte, err error) { type PacketMessage struct { - Origin string `json:"origin"` - OriginID string `json:"origin_id"` - Timestamp string `json:"timestamp"` - Type string `json:"type"` - Direction string `json:"direction"` - Time string `json:"time"` - Date string `json:"date"` - Len string `json:"len"` - PacketType string `json:"packet_type"` - Route string `json:"route"` - PayloadLen string `json:"payload_len"` - Raw string `json:"raw"` - SNR string `json:"SNR"` - RSSI string `json:"RSSI"` - Score string `json:"score"` - Duration string `json:"duration"` - Hash string `json:"hash"` + Origin string `json:"origin"` + OriginID string `json:"origin_id"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Direction string `json:"direction"` + Time string `json:"time"` + Date string `json:"date"` + Len flexString `json:"len"` + PacketType flexString `json:"packet_type"` + Route string `json:"route"` + PayloadLen flexString `json:"payload_len"` + Raw string `json:"raw"` + SNR flexString `json:"SNR"` + RSSI flexString `json:"RSSI"` + Score flexString `json:"score"` + Duration flexString `json:"duration"` + Hash string `json:"hash"` } var pkt PacketMessage if err = json.Unmarshal(payload, &pkt); err != nil { diff --git a/ingest/cmd/meshcoreingest/main_test.go b/ingest/cmd/meshcoreingest/main_test.go index 8009d74..f7bdcea 100644 --- a/ingest/cmd/meshcoreingest/main_test.go +++ b/ingest/cmd/meshcoreingest/main_test.go @@ -265,3 +265,54 @@ func TestExtractGatewayID(t *testing.T) { }) } } + +// Some gateways encode the numeric metric fields — SNR, RSSI, len, etc. — as +// bare JSON numbers, including negative and fractional values, rather than +// strings. These must parse rather than dropping the whole packet (and the raw +// bytes we need with it). +func TestParseMeshCorePacketsMessage_NumericMetrics(t *testing.T) { + payload := []byte(`{ + "timestamp": "2026-06-19T07:03:43.000000", + "origin": "PDX Gateway Bridge 14", + "origin_id": "FACE69B85A0D184C7D122164BDFADE87F25069455C85DD2824CAD3F6AA0B1929", + "type": "PACKET", "direction": "rx", "len": 38, "payload_len": 20, + "packet_type": 1, "route": "F", + "raw": "05481D6B54CA61006000AEE498916968452A7994F827AFB6CE312721FFBE377BA3D113F924C6", + "SNR": -9.25, "RSSI": -95, "score": 1000, "duration": 0 + }`) + origin, originPubkey, meshTimestamp, packet, err := parseMeshCorePacketsMessage(payload) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if origin != "PDX Gateway Bridge 14" { + t.Errorf("unexpected origin: %s", origin) + } + if hex.EncodeToString(originPubkey) != strings.ToLower("FACE69B85A0D184C7D122164BDFADE87F25069455C85DD2824CAD3F6AA0B1929") { + t.Errorf("unexpected origin_pubkey: %s", hex.EncodeToString(originPubkey)) + } + if meshTimestamp.Format(time.RFC3339Nano) != "2026-06-19T07:03:43Z" { + t.Errorf("unexpected meshTimestamp: %s", meshTimestamp) + } + if len(packet) == 0 { + t.Error("packet should not be empty") + } +} + +// The same fields quoted as strings (other gateways) must keep working too. +func TestParseMeshCorePacketsMessage_StringMetrics(t *testing.T) { + payload := []byte(`{ + "timestamp": "2026-06-19T07:03:43.000000", + "origin": "PDX Gateway Bridge 14", + "origin_id": "FACE69B85A0D184C7D122164BDFADE87F25069455C85DD2824CAD3F6AA0B1929", + "type": "PACKET", "direction": "rx", "len": "38", "payload_len": "20", + "raw": "05481D6B54CA61006000AEE498916968452A7994F827AFB6CE312721FFBE377BA3D113F924C6", + "SNR": "11.8", "RSSI": "-38" + }`) + _, _, _, packet, err := parseMeshCorePacketsMessage(payload) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(packet) == 0 { + t.Error("packet should not be empty") + } +}