diff --git a/decoder/decoder.go b/decoder/decoder.go index 99ac58d..e9b5925 100644 --- a/decoder/decoder.go +++ b/decoder/decoder.go @@ -4,10 +4,8 @@ import ( "encoding/json" "fmt" "strings" - "time" "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/encoding/protojson" pb "meshstream/proto/generated/meshtastic" ) @@ -299,830 +297,4 @@ func IsASCII(data []byte) bool { } } return true -} - -// FormatTelemetryMessage formats a Telemetry message -func FormatTelemetryMessage(telemetry *pb.Telemetry) string { - var builder strings.Builder - - if telemetry == nil { - return "Error: nil telemetry data" - } - - builder.WriteString("Telemetry Data:\n") - - if telemetry.GetTime() != 0 { - tm := time.Unix(int64(telemetry.GetTime()), 0) - builder.WriteString(fmt.Sprintf(" Time: %s\n", tm.Format(time.RFC3339))) - } - - // Check which telemetry type we have - if env := telemetry.GetEnvironmentMetrics(); env != nil { - builder.WriteString(" Environment Telemetry:\n") - - temp := env.GetTemperature() - if temp != 0 { - builder.WriteString(fmt.Sprintf(" Temperature: %.2f °C\n", temp)) - } - - humidity := env.GetRelativeHumidity() - if humidity != 0 { - builder.WriteString(fmt.Sprintf(" Humidity: %.2f %%\n", humidity)) - } - - pressure := env.GetBarometricPressure() - if pressure != 0 { - builder.WriteString(fmt.Sprintf(" Pressure: %.2f hPa\n", pressure)) - } - - gasRes := env.GetGasResistance() - if gasRes != 0 { - builder.WriteString(fmt.Sprintf(" Gas Resistance: %.2f MΩ\n", gasRes)) - } - - iaq := env.GetIaq() - if iaq != 0 { - builder.WriteString(fmt.Sprintf(" Air Quality (IAQ): %d\n", iaq)) - } - - distance := env.GetDistance() - if distance != 0 { - builder.WriteString(fmt.Sprintf(" Distance: %.1f mm\n", distance)) - } - - lux := env.GetLux() - if lux != 0 { - builder.WriteString(fmt.Sprintf(" Light: %.1f lux\n", lux)) - } - - windDir := env.GetWindDirection() - if windDir != 0 { - directions := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"} - dirIndex := int(windDir / 45) % 8 - builder.WriteString(fmt.Sprintf(" Wind Direction: %d° (%s)\n", windDir, directions[dirIndex])) - } - - windSpeed := env.GetWindSpeed() - if windSpeed != 0 { - builder.WriteString(fmt.Sprintf(" Wind Speed: %.1f m/s\n", windSpeed)) - } - - windGust := env.GetWindGust() - if windGust != 0 { - builder.WriteString(fmt.Sprintf(" Wind Gust: %.1f m/s\n", windGust)) - } - } - - if power := telemetry.GetPowerMetrics(); power != nil { - builder.WriteString(" Power Telemetry:\n") - - ch1Volt := power.GetCh1Voltage() - if ch1Volt != 0 { - builder.WriteString(fmt.Sprintf(" Channel 1 Voltage: %.2f V\n", ch1Volt)) - } - - ch1Curr := power.GetCh1Current() - if ch1Curr != 0 { - builder.WriteString(fmt.Sprintf(" Channel 1 Current: %.2f mA\n", ch1Curr)) - } - - ch2Volt := power.GetCh2Voltage() - if ch2Volt != 0 { - builder.WriteString(fmt.Sprintf(" Channel 2 Voltage: %.2f V\n", ch2Volt)) - } - - ch2Curr := power.GetCh2Current() - if ch2Curr != 0 { - builder.WriteString(fmt.Sprintf(" Channel 2 Current: %.2f mA\n", ch2Curr)) - } - - ch3Volt := power.GetCh3Voltage() - if ch3Volt != 0 { - builder.WriteString(fmt.Sprintf(" Channel 3 Voltage: %.2f V\n", ch3Volt)) - } - - ch3Curr := power.GetCh3Current() - if ch3Curr != 0 { - builder.WriteString(fmt.Sprintf(" Channel 3 Current: %.2f mA\n", ch3Curr)) - } - } - - if air := telemetry.GetAirQualityMetrics(); air != nil { - builder.WriteString(" Air Quality Telemetry:\n") - - pm10 := air.GetPm10Standard() - if pm10 != 0 { - builder.WriteString(fmt.Sprintf(" PM 1.0: %d µg/m³\n", pm10)) - } - - pm25 := air.GetPm25Standard() - if pm25 != 0 { - builder.WriteString(fmt.Sprintf(" PM 2.5: %d µg/m³\n", pm25)) - } - - pm100 := air.GetPm100Standard() - if pm100 != 0 { - builder.WriteString(fmt.Sprintf(" PM 10.0: %d µg/m³\n", pm100)) - } - - co2 := air.GetCo2() - if co2 != 0 { - builder.WriteString(fmt.Sprintf(" CO2: %d ppm\n", co2)) - } - - // VOC field not available in this version of the proto - } - - // Device metrics - if device := telemetry.GetDeviceMetrics(); device != nil { - builder.WriteString(" Device Metrics:\n") - - batLevel := device.GetBatteryLevel() - if batLevel != 0 { - builder.WriteString(fmt.Sprintf(" Battery Level: %d%%\n", batLevel)) - } - - voltage := device.GetVoltage() - if voltage != 0 { - builder.WriteString(fmt.Sprintf(" Voltage: %.2f V\n", voltage)) - } - - chanUtil := device.GetChannelUtilization() - if chanUtil != 0 { - builder.WriteString(fmt.Sprintf(" Channel Utilization: %.2f%%\n", chanUtil)) - } - - airUtil := device.GetAirUtilTx() - if airUtil != 0 { - builder.WriteString(fmt.Sprintf(" Air Utilization TX: %.2f%%\n", airUtil)) - } - - uptime := device.GetUptimeSeconds() - if uptime != 0 { - uptimeDur := time.Duration(uptime) * time.Second - builder.WriteString(fmt.Sprintf(" Uptime: %s\n", uptimeDur)) - } - } - - // Local stats - if stats := telemetry.GetLocalStats(); stats != nil { - builder.WriteString(" Local Statistics:\n") - if stats.GetUptimeSeconds() != 0 { - uptime := time.Duration(stats.GetUptimeSeconds()) * time.Second - builder.WriteString(fmt.Sprintf(" Uptime: %s\n", uptime)) - } - if stats.GetChannelUtilization() != 0 { - builder.WriteString(fmt.Sprintf(" Channel Utilization: %.2f%%\n", stats.GetChannelUtilization())) - } - if stats.GetAirUtilTx() != 0 { - builder.WriteString(fmt.Sprintf(" Air Utilization TX: %.2f%%\n", stats.GetAirUtilTx())) - } - if stats.GetNumPacketsTx() != 0 { - builder.WriteString(fmt.Sprintf(" Packets Transmitted: %d\n", stats.GetNumPacketsTx())) - } - if stats.GetNumPacketsRx() != 0 { - builder.WriteString(fmt.Sprintf(" Packets Received: %d\n", stats.GetNumPacketsRx())) - } - if stats.GetNumPacketsRxBad() != 0 { - builder.WriteString(fmt.Sprintf(" Bad Packets Received: %d\n", stats.GetNumPacketsRxBad())) - } - if stats.GetNumOnlineNodes() != 0 { - builder.WriteString(fmt.Sprintf(" Online Nodes: %d\n", stats.GetNumOnlineNodes())) - } - if stats.GetNumTotalNodes() != 0 { - builder.WriteString(fmt.Sprintf(" Total Nodes: %d\n", stats.GetNumTotalNodes())) - } - } - - // Health metrics - if health := telemetry.GetHealthMetrics(); health != nil { - builder.WriteString(" Health Metrics:\n") - - heartBpm := health.GetHeartBpm() - if heartBpm != 0 { - builder.WriteString(fmt.Sprintf(" Heart Rate: %d bpm\n", heartBpm)) - } - - spo2 := health.GetSpO2() - if spo2 != 0 { - builder.WriteString(fmt.Sprintf(" SpO2: %d%%\n", spo2)) - } - - temp := health.GetTemperature() - if temp != 0 { - builder.WriteString(fmt.Sprintf(" Body Temperature: %.1f °C\n", temp)) - } - } - - // Marshal to JSON for detailed view - jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(telemetry) - if err == nil { - builder.WriteString("\nFull Telemetry Structure:\n") - builder.WriteString(string(jsonBytes)) - } - - return builder.String() -} - -// FormatPositionMessage formats a Position message -func FormatPositionMessage(position *pb.Position) string { - var builder strings.Builder - - if position == nil { - return "Error: nil position data" - } - - builder.WriteString("Position Data:\n") - - // Check if we have valid position data - if position.GetLatitudeI() != 0 && position.GetLongitudeI() != 0 { - // Convert the integer coordinates to floating-point degrees - // Meshtastic uses a format where the values are stored as integers - // representing the position multiplied by 1e7 (10 million) - lat := float64(position.GetLatitudeI()) / 10000000.0 - lon := float64(position.GetLongitudeI()) / 10000000.0 - - builder.WriteString(fmt.Sprintf(" Latitude: %.7f\n", lat)) - builder.WriteString(fmt.Sprintf(" Longitude: %.7f\n", lon)) - - // Google Maps link for convenience - builder.WriteString(fmt.Sprintf(" Google Maps: https://maps.google.com/?q=%.7f,%.7f\n", lat, lon)) - } else { - builder.WriteString(" No valid latitude/longitude data\n") - } - - // Add altitude if available - if position.GetAltitude() != 0 { - builder.WriteString(fmt.Sprintf(" Altitude: %d meters\n", position.GetAltitude())) - } - - // Add time information - if position.GetTime() != 0 { - builder.WriteString(fmt.Sprintf(" Time: %d\n", position.GetTime())) - } - - if position.GetTimestamp() != 0 { - // Convert UNIX timestamp to readable time - tm := time.Unix(int64(position.GetTimestamp()), 0) - builder.WriteString(fmt.Sprintf(" Timestamp: %s\n", tm.Format(time.RFC3339))) - - if position.GetTimestampMillisAdjust() != 0 { - builder.WriteString(fmt.Sprintf(" Millis adjustment: %d\n", position.GetTimestampMillisAdjust())) - } - } - - // Source info - if position.GetLocationSource() != pb.Position_LOC_UNSET { - builder.WriteString(fmt.Sprintf(" Location Source: %s\n", position.GetLocationSource())) - } - - if position.GetAltitudeSource() != pb.Position_ALT_UNSET { - builder.WriteString(fmt.Sprintf(" Altitude Source: %s\n", position.GetAltitudeSource())) - } - - // GPS quality information if available - if position.GetPDOP() != 0 { - builder.WriteString(fmt.Sprintf(" PDOP: %.1f\n", float64(position.GetPDOP())/10)) - } - if position.GetHDOP() != 0 { - builder.WriteString(fmt.Sprintf(" HDOP: %.1f\n", float64(position.GetHDOP())/10)) - } - if position.GetVDOP() != 0 { - builder.WriteString(fmt.Sprintf(" VDOP: %.1f\n", float64(position.GetVDOP())/10)) - } - if position.GetGpsAccuracy() != 0 { - builder.WriteString(fmt.Sprintf(" GPS Accuracy: %d meters\n", position.GetGpsAccuracy())) - } - if position.GetGroundSpeed() != 0 { - builder.WriteString(fmt.Sprintf(" Ground Speed: %.1f m/s\n", float64(position.GetGroundSpeed())/100)) - } - if position.GetGroundTrack() != 0 { - builder.WriteString(fmt.Sprintf(" Ground Track: %d°\n", position.GetGroundTrack())) - } - if position.GetSatsInView() != 0 { - builder.WriteString(fmt.Sprintf(" Satellites in view: %d\n", position.GetSatsInView())) - } - - // Marshal to JSON for detailed view - jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(position) - if err == nil { - builder.WriteString("\nFull Position Structure:\n") - builder.WriteString(string(jsonBytes)) - } - - return builder.String() -} - -// FormatNodeInfoMessage formats a User message (used by the NODEINFO_APP port) -func FormatNodeInfoMessage(user *pb.User) string { - var builder strings.Builder - - if user == nil { - return "Error: nil user data" - } - - builder.WriteString("Node Information:\n") - - if user.GetId() != "" { - builder.WriteString(fmt.Sprintf(" ID: %s\n", user.GetId())) - } - - if user.GetLongName() != "" { - builder.WriteString(fmt.Sprintf(" Name: %s\n", user.GetLongName())) - } - - if user.GetShortName() != "" { - builder.WriteString(fmt.Sprintf(" Short Name: %s\n", user.GetShortName())) - } - - if user.GetHwModel() != pb.HardwareModel_UNSET { - builder.WriteString(fmt.Sprintf(" Hardware: %s\n", user.GetHwModel())) - } - - if user.GetIsLicensed() { - builder.WriteString(" Licensed HAM operator\n") - } - - if user.GetRole() != 0 { - builder.WriteString(fmt.Sprintf(" Role: %s\n", user.GetRole())) - } - - if len(user.GetPublicKey()) > 0 { - builder.WriteString(fmt.Sprintf(" Public Key: %x\n", user.GetPublicKey())) - } - - // Marshal to JSON for detailed view - jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(user) - if err == nil { - builder.WriteString("\nFull User Structure:\n") - builder.WriteString(string(jsonBytes)) - } - - return builder.String() -} - -// FormatWaypointMessage formats a Waypoint message -func FormatWaypointMessage(waypoint *pb.Waypoint) string { - var builder strings.Builder - - if waypoint == nil { - return "Error: nil waypoint data" - } - - builder.WriteString("Waypoint:\n") - - if waypoint.GetId() != 0 { - builder.WriteString(fmt.Sprintf(" ID: %d\n", waypoint.GetId())) - } - - if waypoint.GetName() != "" { - builder.WriteString(fmt.Sprintf(" Name: %s\n", waypoint.GetName())) - } - - if waypoint.GetDescription() != "" { - builder.WriteString(fmt.Sprintf(" Description: %s\n", waypoint.GetDescription())) - } - - if waypoint.GetLatitudeI() != 0 && waypoint.GetLongitudeI() != 0 { - // Convert the integer coordinates to floating-point degrees - lat := float64(waypoint.GetLatitudeI()) / 10000000.0 - lon := float64(waypoint.GetLongitudeI()) / 10000000.0 - - builder.WriteString(fmt.Sprintf(" Latitude: %.7f\n", lat)) - builder.WriteString(fmt.Sprintf(" Longitude: %.7f\n", lon)) - - // Google Maps link for convenience - builder.WriteString(fmt.Sprintf(" Google Maps: https://maps.google.com/?q=%.7f,%.7f\n", lat, lon)) - } - - if waypoint.GetIcon() != 0 { - builder.WriteString(fmt.Sprintf(" Icon: %d\n", waypoint.GetIcon())) - } - - if waypoint.GetExpire() != 0 { - // Convert UNIX timestamp to readable time - tm := time.Unix(int64(waypoint.GetExpire()), 0) - builder.WriteString(fmt.Sprintf(" Expires: %s\n", tm.Format(time.RFC3339))) - } - - if waypoint.GetLockedTo() != 0 { - builder.WriteString(fmt.Sprintf(" Locked to node: %d\n", waypoint.GetLockedTo())) - } - - // Marshal to JSON for detailed view - jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(waypoint) - if err == nil { - builder.WriteString("\nFull Waypoint Structure:\n") - builder.WriteString(string(jsonBytes)) - } - - return builder.String() -} - -// FormatData formats a Data message (deprecated - use FormatPayload instead) -func FormatData(data *pb.Data) string { - if data == nil { - return "Error: nil data" - } - - decoded := &DecodedPacket{ - PortNum: data.GetPortnum(), - RequestID: data.GetRequestId(), - ReplyID: data.GetReplyId(), - Emoji: data.GetEmoji(), - Dest: data.GetDest(), - Source: data.GetSource(), - WantResponse: data.GetWantResponse(), - RawData: data, - } - - // Process payload based on type - payload := data.GetPayload() - switch data.GetPortnum() { - case pb.PortNum_TEXT_MESSAGE_APP: - decoded.Payload = string(payload) - case pb.PortNum_TELEMETRY_APP: - var telemetry pb.Telemetry - if err := proto.Unmarshal(payload, &telemetry); err == nil { - decoded.Payload = &telemetry - } else { - decoded.Payload = payload - } - case pb.PortNum_POSITION_APP: - var position pb.Position - if err := proto.Unmarshal(payload, &position); err == nil { - decoded.Payload = &position - } else { - decoded.Payload = payload - } - case pb.PortNum_NODEINFO_APP: - var user pb.User - if err := proto.Unmarshal(payload, &user); err == nil { - decoded.Payload = &user - } else { - decoded.Payload = payload - } - case pb.PortNum_WAYPOINT_APP: - var waypoint pb.Waypoint - if err := proto.Unmarshal(payload, &waypoint); err == nil { - decoded.Payload = &waypoint - } else { - decoded.Payload = payload - } - default: - decoded.Payload = payload - } - - // Format with our new formatter - var builder strings.Builder - builder.WriteString(fmt.Sprintf("Data (Port: %s):\n", data.GetPortnum())) - builder.WriteString(FormatPayload(decoded.Payload, decoded.PortNum)) - - // Show additional Data fields - if data.GetRequestId() != 0 { - builder.WriteString(fmt.Sprintf(" Request ID: %d\n", data.GetRequestId())) - } - if data.GetReplyId() != 0 { - builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", data.GetReplyId())) - } - if data.GetEmoji() != 0 { - builder.WriteString(fmt.Sprintf(" Emoji: %d\n", data.GetEmoji())) - } - if data.GetDest() != 0 { - builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", data.GetDest())) - } - if data.GetSource() != 0 { - builder.WriteString(fmt.Sprintf(" Source Node: %d\n", data.GetSource())) - } - if data.GetWantResponse() { - builder.WriteString(" Wants Response: Yes\n") - } - - return builder.String() -} - -// FormatPayload formats a payload based on its type and port number -func FormatPayload(payload interface{}, portNum pb.PortNum) string { - if payload == nil { - return " No payload data\n" - } - - switch portNum { - case pb.PortNum_TEXT_MESSAGE_APP: - // Text message - if text, ok := payload.(string); ok { - return fmt.Sprintf(" Text Message: %s\n", text) - } - - case pb.PortNum_TEXT_MESSAGE_COMPRESSED_APP: - // Compressed text - if data, ok := payload.([]byte); ok { - return fmt.Sprintf(" Compressed Text Message (%d bytes): %x\n", len(data), data) - // TODO: Add decompression support - } - - case pb.PortNum_TELEMETRY_APP: - // Telemetry data - if telemetry, ok := payload.(*pb.Telemetry); ok { - return FormatTelemetryMessage(telemetry) - } - - case pb.PortNum_POSITION_APP: - // Position data - if position, ok := payload.(*pb.Position); ok { - return FormatPositionMessage(position) - } - - case pb.PortNum_NODEINFO_APP: - // Node information - if user, ok := payload.(*pb.User); ok { - return FormatNodeInfoMessage(user) - } - - case pb.PortNum_WAYPOINT_APP: - // Waypoint data - if waypoint, ok := payload.(*pb.Waypoint); ok { - return FormatWaypointMessage(waypoint) - } - } - - // Default formatting for unknown types - switch v := payload.(type) { - case string: - return fmt.Sprintf(" Text: %s\n", v) - case []byte: - result := fmt.Sprintf(" Binary (%d bytes): %x\n", len(v), v) - if IsASCII(v) { - result += fmt.Sprintf(" As text: %s\n", string(v)) - } - return result - default: - return fmt.Sprintf(" Payload of type %T\n", payload) - } -} - -// FormatDecodedPacket formats a DecodedPacket into a human-readable string -func FormatDecodedPacket(packet *DecodedPacket) string { - var builder strings.Builder - - if packet == nil { - return "Error: nil packet" - } - - if packet.DecodeError != nil { - builder.WriteString(fmt.Sprintf("Error decoding packet: %v\n", packet.DecodeError)) - return builder.String() - } - - // Envelope info - builder.WriteString("Packet:\n") - builder.WriteString(fmt.Sprintf(" Channel ID: %s\n", packet.ChannelID)) - if packet.GatewayID != "" { - builder.WriteString(fmt.Sprintf(" Gateway ID: %s\n", packet.GatewayID)) - } - - // Mesh packet info - builder.WriteString(fmt.Sprintf(" ID: %d\n", packet.ID)) - builder.WriteString(fmt.Sprintf(" From: %d\n", packet.From)) - builder.WriteString(fmt.Sprintf(" To: %d\n", packet.To)) - - // Additional fields that might be interesting - builder.WriteString(fmt.Sprintf(" Port: %s\n", packet.PortNum)) - - if packet.HopLimit != 0 { - builder.WriteString(fmt.Sprintf(" Hop Limit: %d\n", packet.HopLimit)) - } - if packet.HopStart != 0 { - builder.WriteString(fmt.Sprintf(" Hop Start: %d\n", packet.HopStart)) - } - if packet.WantACK { - builder.WriteString(" Wants ACK: Yes\n") - } - if packet.ViaMQTT { - builder.WriteString(" Via MQTT: Yes\n") - } - if packet.NextHop != 0 { - builder.WriteString(fmt.Sprintf(" Next Hop: %d\n", packet.NextHop)) - } - if packet.RelayNode != 0 { - builder.WriteString(fmt.Sprintf(" Relay Node: %d\n", packet.RelayNode)) - } - - // Additional data fields - if packet.RequestID != 0 { - builder.WriteString(fmt.Sprintf(" Request ID: %d\n", packet.RequestID)) - } - if packet.ReplyID != 0 { - builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", packet.ReplyID)) - } - if packet.Emoji != 0 { - builder.WriteString(fmt.Sprintf(" Emoji: %d\n", packet.Emoji)) - } - if packet.Dest != 0 { - builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", packet.Dest)) - } - if packet.Source != 0 { - builder.WriteString(fmt.Sprintf(" Source Node: %d\n", packet.Source)) - } - if packet.WantResponse { - builder.WriteString(" Wants Response: Yes\n") - } - - // Format the payload - builder.WriteString("\n") - builder.WriteString(FormatPayload(packet.Payload, packet.PortNum)) - - return builder.String() -} - -// FormatServiceEnvelope formats a ServiceEnvelope message into a human-readable string -func FormatServiceEnvelope(envelope *pb.ServiceEnvelope) string { - var builder strings.Builder - - builder.WriteString("ServiceEnvelope:\n") - - // Print basic envelope info - builder.WriteString(fmt.Sprintf(" Channel ID: %s\n", envelope.GetChannelId())) - builder.WriteString(fmt.Sprintf(" Gateway ID: %s\n", envelope.GetGatewayId())) - - // Print MeshPacket info if available - if packet := envelope.GetPacket(); packet != nil { - builder.WriteString("\nMeshPacket:\n") - builder.WriteString(fmt.Sprintf(" ID: %d\n", packet.GetId())) - builder.WriteString(fmt.Sprintf(" From: %d\n", packet.GetFrom())) - builder.WriteString(fmt.Sprintf(" To: %d\n", packet.GetTo())) - - // Output routing and hop information - builder.WriteString(fmt.Sprintf(" Hop Limit: %d\n", packet.GetHopLimit())) - builder.WriteString(fmt.Sprintf(" Hop Start: %d\n", packet.GetHopStart())) - builder.WriteString(fmt.Sprintf(" Want ACK: %v\n", packet.GetWantAck())) - builder.WriteString(fmt.Sprintf(" Priority: %s\n", packet.GetPriority())) - - // Output if the packet was delivered via MQTT - if packet.GetViaMqtt() { - builder.WriteString(" Via MQTT: Yes\n") - } - - // Relay and next hop info - if packet.GetNextHop() != 0 { - builder.WriteString(fmt.Sprintf(" Next Hop: %d\n", packet.GetNextHop())) - } - if packet.GetRelayNode() != 0 { - builder.WriteString(fmt.Sprintf(" Relay Node: %d\n", packet.GetRelayNode())) - } - - // Show public key information if available (for PKI-encrypted packets) - if len(packet.GetPublicKey()) > 0 { - builder.WriteString(fmt.Sprintf(" Public Key: %x\n", packet.GetPublicKey())) - builder.WriteString(fmt.Sprintf(" PKI Encrypted: %v\n", packet.GetPkiEncrypted())) - } - - // Determine payload type - if packet.GetDecoded() != nil { - // For already decoded packets, use our specialized data formatter - builder.WriteString("\n") - builder.WriteString(FormatData(packet.GetDecoded())) - - } else if packet.GetEncrypted() != nil { - // Encrypted payload information - builder.WriteString("\n Encrypted Payload:\n") - builder.WriteString(fmt.Sprintf(" Size: %d bytes\n", len(packet.GetEncrypted()))) - builder.WriteString(fmt.Sprintf(" Channel: %d\n", packet.GetChannel())) - - // Print the first few bytes of the encrypted payload for identification - if len(packet.GetEncrypted()) > 0 { - displayLen := len(packet.GetEncrypted()) - if displayLen > 16 { - displayLen = 16 - } - builder.WriteString(fmt.Sprintf(" First %d bytes: %x\n", displayLen, packet.GetEncrypted()[:displayLen])) - } - - // If the packet has channel ID, it's using channel-based encryption - channelId := envelope.GetChannelId() - if channelId != "" { - builder.WriteString(fmt.Sprintf(" Encryption: Channel-based (Channel ID: %s)\n", channelId)) - - // Attempt to decrypt the payload using the channel key - channelKey := GetChannelKey(channelId) - builder.WriteString(fmt.Sprintf(" Using key (%d bytes): %x\n", len(channelKey), channelKey)) - - // Try to decrypt - decrypted, err := XOR(packet.GetEncrypted(), channelKey, packet.GetId(), packet.GetFrom()) - if err != nil { - builder.WriteString(fmt.Sprintf(" Decryption error: %v\n", err)) - } else { - builder.WriteString(fmt.Sprintf(" Decrypted (%d bytes): %x\n", len(decrypted), decrypted)) - - // Try to parse the decrypted payload as a Data message - var data pb.Data - if err := proto.Unmarshal(decrypted, &data); err == nil { - // Successfully decoded the decrypted payload into a Data message - // Use our specialized data formatter to show the message details - builder.WriteString("\n") - builder.WriteString(indent(FormatData(&data), " ")) - } else { - // If we couldn't parse as Data, try to interpret as text - if IsASCII(decrypted) { - builder.WriteString(fmt.Sprintf(" Decrypted as text: %s\n", string(decrypted))) - } else { - builder.WriteString(fmt.Sprintf(" Failed to parse decrypted data as pb.Data: %v\n", err)) - } - } - } - } - } - } - - // Use protojson to generate a full JSON representation for debugging - marshaler := protojson.MarshalOptions{ - Multiline: true, - Indent: " ", - } - jsonBytes, err := marshaler.Marshal(envelope) - if err == nil { - builder.WriteString("\nFull Protobuf Structure:\n") - builder.WriteString(string(jsonBytes)) - } - - return builder.String() -} - -// indent adds indentation to each line of a string -func indent(s, prefix string) string { - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = prefix + line - } - } - return strings.Join(lines, "\n") -} - -// FormatJSONMessage formats a JSON message into a human-readable string -func FormatJSONMessage(jsonData map[string]interface{}) string { - var builder strings.Builder - - builder.WriteString("JSON Message:\n") - - // Extract and display common fields - if from, ok := jsonData["from"].(string); ok { - builder.WriteString(fmt.Sprintf(" From: %s\n", from)) - } - if to, ok := jsonData["to"].(string); ok { - builder.WriteString(fmt.Sprintf(" To: %s\n", to)) - } - if message, ok := jsonData["payload"].(string); ok { - builder.WriteString(fmt.Sprintf(" Message: %s\n", message)) - } - if timestamp, ok := jsonData["timestamp"].(string); ok { - builder.WriteString(fmt.Sprintf(" Timestamp: %s\n", timestamp)) - } - - // Format the full JSON for reference - jsonBytes, err := json.MarshalIndent(jsonData, " ", " ") - if err == nil { - builder.WriteString("\nFull JSON Structure:\n ") - builder.WriteString(string(jsonBytes)) - } - - return builder.String() -} - -// FormatMessage formats a decoded message based on its format -func FormatMessage(topicInfo *TopicInfo, payload []byte) string { - var builder strings.Builder - - // Display topic information - builder.WriteString(fmt.Sprintf("Topic: %s\n", topicInfo.FullTopic)) - builder.WriteString(fmt.Sprintf("Region Path: %s\n", topicInfo.RegionPath)) - builder.WriteString(fmt.Sprintf("Version: %s\n", topicInfo.Version)) - builder.WriteString(fmt.Sprintf("Format: %s\n", topicInfo.Format)) - builder.WriteString(fmt.Sprintf("Channel: %s\n", topicInfo.Channel)) - if topicInfo.UserID != "" { - builder.WriteString(fmt.Sprintf("User ID: %s\n", topicInfo.UserID)) - } - builder.WriteString("\n") - - // Decode and format based on the format - if topicInfo.Format == "e" { - // Encoded protobuf message - use our new decoder - decodedPacket := DecodeMessage(payload, topicInfo) - builder.WriteString(FormatDecodedPacket(decodedPacket)) - } else if topicInfo.Format == "json" { - // JSON message - jsonData, err := DecodeJSONMessage(payload) - if err != nil { - builder.WriteString(fmt.Sprintf("Error decoding JSON message: %v\n", err)) - builder.WriteString(fmt.Sprintf("Raw Data: %s\n", string(payload))) - } else { - builder.WriteString(FormatJSONMessage(jsonData)) - } - } else { - // Unknown format - builder.WriteString(fmt.Sprintf("Unsupported format: %s\n", topicInfo.Format)) - builder.WriteString(fmt.Sprintf("Raw Data (%d bytes): %x\n", len(payload), payload)) - } - - return builder.String() } \ No newline at end of file diff --git a/decoder/formatter.go b/decoder/formatter.go new file mode 100644 index 0000000..ff47f4d --- /dev/null +++ b/decoder/formatter.go @@ -0,0 +1,839 @@ +package decoder + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + pb "meshstream/proto/generated/meshtastic" +) + +// FormatTelemetryMessage formats a Telemetry message +func FormatTelemetryMessage(telemetry *pb.Telemetry) string { + var builder strings.Builder + + if telemetry == nil { + return "Error: nil telemetry data" + } + + builder.WriteString("Telemetry Data:\n") + + if telemetry.GetTime() != 0 { + tm := time.Unix(int64(telemetry.GetTime()), 0) + builder.WriteString(fmt.Sprintf(" Time: %s\n", tm.Format(time.RFC3339))) + } + + // Check which telemetry type we have + if env := telemetry.GetEnvironmentMetrics(); env != nil { + builder.WriteString(" Environment Telemetry:\n") + + temp := env.GetTemperature() + if temp != 0 { + builder.WriteString(fmt.Sprintf(" Temperature: %.2f °C\n", temp)) + } + + humidity := env.GetRelativeHumidity() + if humidity != 0 { + builder.WriteString(fmt.Sprintf(" Humidity: %.2f %%\n", humidity)) + } + + pressure := env.GetBarometricPressure() + if pressure != 0 { + builder.WriteString(fmt.Sprintf(" Pressure: %.2f hPa\n", pressure)) + } + + gasRes := env.GetGasResistance() + if gasRes != 0 { + builder.WriteString(fmt.Sprintf(" Gas Resistance: %.2f MΩ\n", gasRes)) + } + + iaq := env.GetIaq() + if iaq != 0 { + builder.WriteString(fmt.Sprintf(" Air Quality (IAQ): %d\n", iaq)) + } + + distance := env.GetDistance() + if distance != 0 { + builder.WriteString(fmt.Sprintf(" Distance: %.1f mm\n", distance)) + } + + lux := env.GetLux() + if lux != 0 { + builder.WriteString(fmt.Sprintf(" Light: %.1f lux\n", lux)) + } + + windDir := env.GetWindDirection() + if windDir != 0 { + directions := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"} + dirIndex := int(windDir / 45) % 8 + builder.WriteString(fmt.Sprintf(" Wind Direction: %d° (%s)\n", windDir, directions[dirIndex])) + } + + windSpeed := env.GetWindSpeed() + if windSpeed != 0 { + builder.WriteString(fmt.Sprintf(" Wind Speed: %.1f m/s\n", windSpeed)) + } + + windGust := env.GetWindGust() + if windGust != 0 { + builder.WriteString(fmt.Sprintf(" Wind Gust: %.1f m/s\n", windGust)) + } + } + + if power := telemetry.GetPowerMetrics(); power != nil { + builder.WriteString(" Power Telemetry:\n") + + ch1Volt := power.GetCh1Voltage() + if ch1Volt != 0 { + builder.WriteString(fmt.Sprintf(" Channel 1 Voltage: %.2f V\n", ch1Volt)) + } + + ch1Curr := power.GetCh1Current() + if ch1Curr != 0 { + builder.WriteString(fmt.Sprintf(" Channel 1 Current: %.2f mA\n", ch1Curr)) + } + + ch2Volt := power.GetCh2Voltage() + if ch2Volt != 0 { + builder.WriteString(fmt.Sprintf(" Channel 2 Voltage: %.2f V\n", ch2Volt)) + } + + ch2Curr := power.GetCh2Current() + if ch2Curr != 0 { + builder.WriteString(fmt.Sprintf(" Channel 2 Current: %.2f mA\n", ch2Curr)) + } + + ch3Volt := power.GetCh3Voltage() + if ch3Volt != 0 { + builder.WriteString(fmt.Sprintf(" Channel 3 Voltage: %.2f V\n", ch3Volt)) + } + + ch3Curr := power.GetCh3Current() + if ch3Curr != 0 { + builder.WriteString(fmt.Sprintf(" Channel 3 Current: %.2f mA\n", ch3Curr)) + } + } + + if air := telemetry.GetAirQualityMetrics(); air != nil { + builder.WriteString(" Air Quality Telemetry:\n") + + pm10 := air.GetPm10Standard() + if pm10 != 0 { + builder.WriteString(fmt.Sprintf(" PM 1.0: %d µg/m³\n", pm10)) + } + + pm25 := air.GetPm25Standard() + if pm25 != 0 { + builder.WriteString(fmt.Sprintf(" PM 2.5: %d µg/m³\n", pm25)) + } + + pm100 := air.GetPm100Standard() + if pm100 != 0 { + builder.WriteString(fmt.Sprintf(" PM 10.0: %d µg/m³\n", pm100)) + } + + co2 := air.GetCo2() + if co2 != 0 { + builder.WriteString(fmt.Sprintf(" CO2: %d ppm\n", co2)) + } + + // VOC field not available in this version of the proto + } + + // Device metrics + if device := telemetry.GetDeviceMetrics(); device != nil { + builder.WriteString(" Device Metrics:\n") + + batLevel := device.GetBatteryLevel() + if batLevel != 0 { + builder.WriteString(fmt.Sprintf(" Battery Level: %d%%\n", batLevel)) + } + + voltage := device.GetVoltage() + if voltage != 0 { + builder.WriteString(fmt.Sprintf(" Voltage: %.2f V\n", voltage)) + } + + chanUtil := device.GetChannelUtilization() + if chanUtil != 0 { + builder.WriteString(fmt.Sprintf(" Channel Utilization: %.2f%%\n", chanUtil)) + } + + airUtil := device.GetAirUtilTx() + if airUtil != 0 { + builder.WriteString(fmt.Sprintf(" Air Utilization TX: %.2f%%\n", airUtil)) + } + + uptime := device.GetUptimeSeconds() + if uptime != 0 { + uptimeDur := time.Duration(uptime) * time.Second + builder.WriteString(fmt.Sprintf(" Uptime: %s\n", uptimeDur)) + } + } + + // Local stats + if stats := telemetry.GetLocalStats(); stats != nil { + builder.WriteString(" Local Statistics:\n") + if stats.GetUptimeSeconds() != 0 { + uptime := time.Duration(stats.GetUptimeSeconds()) * time.Second + builder.WriteString(fmt.Sprintf(" Uptime: %s\n", uptime)) + } + if stats.GetChannelUtilization() != 0 { + builder.WriteString(fmt.Sprintf(" Channel Utilization: %.2f%%\n", stats.GetChannelUtilization())) + } + if stats.GetAirUtilTx() != 0 { + builder.WriteString(fmt.Sprintf(" Air Utilization TX: %.2f%%\n", stats.GetAirUtilTx())) + } + if stats.GetNumPacketsTx() != 0 { + builder.WriteString(fmt.Sprintf(" Packets Transmitted: %d\n", stats.GetNumPacketsTx())) + } + if stats.GetNumPacketsRx() != 0 { + builder.WriteString(fmt.Sprintf(" Packets Received: %d\n", stats.GetNumPacketsRx())) + } + if stats.GetNumPacketsRxBad() != 0 { + builder.WriteString(fmt.Sprintf(" Bad Packets Received: %d\n", stats.GetNumPacketsRxBad())) + } + if stats.GetNumOnlineNodes() != 0 { + builder.WriteString(fmt.Sprintf(" Online Nodes: %d\n", stats.GetNumOnlineNodes())) + } + if stats.GetNumTotalNodes() != 0 { + builder.WriteString(fmt.Sprintf(" Total Nodes: %d\n", stats.GetNumTotalNodes())) + } + } + + // Health metrics + if health := telemetry.GetHealthMetrics(); health != nil { + builder.WriteString(" Health Metrics:\n") + + heartBpm := health.GetHeartBpm() + if heartBpm != 0 { + builder.WriteString(fmt.Sprintf(" Heart Rate: %d bpm\n", heartBpm)) + } + + spo2 := health.GetSpO2() + if spo2 != 0 { + builder.WriteString(fmt.Sprintf(" SpO2: %d%%\n", spo2)) + } + + temp := health.GetTemperature() + if temp != 0 { + builder.WriteString(fmt.Sprintf(" Body Temperature: %.1f °C\n", temp)) + } + } + + // Marshal to JSON for detailed view + jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(telemetry) + if err == nil { + builder.WriteString("\nFull Telemetry Structure:\n") + builder.WriteString(string(jsonBytes)) + } + + return builder.String() +} + +// FormatPositionMessage formats a Position message +func FormatPositionMessage(position *pb.Position) string { + var builder strings.Builder + + if position == nil { + return "Error: nil position data" + } + + builder.WriteString("Position Data:\n") + + // Check if we have valid position data + if position.GetLatitudeI() != 0 && position.GetLongitudeI() != 0 { + // Convert the integer coordinates to floating-point degrees + // Meshtastic uses a format where the values are stored as integers + // representing the position multiplied by 1e7 (10 million) + lat := float64(position.GetLatitudeI()) / 10000000.0 + lon := float64(position.GetLongitudeI()) / 10000000.0 + + builder.WriteString(fmt.Sprintf(" Latitude: %.7f\n", lat)) + builder.WriteString(fmt.Sprintf(" Longitude: %.7f\n", lon)) + + // Google Maps link for convenience + builder.WriteString(fmt.Sprintf(" Google Maps: https://maps.google.com/?q=%.7f,%.7f\n", lat, lon)) + } else { + builder.WriteString(" No valid latitude/longitude data\n") + } + + // Add altitude if available + if position.GetAltitude() != 0 { + builder.WriteString(fmt.Sprintf(" Altitude: %d meters\n", position.GetAltitude())) + } + + // Add time information + if position.GetTime() != 0 { + builder.WriteString(fmt.Sprintf(" Time: %d\n", position.GetTime())) + } + + if position.GetTimestamp() != 0 { + // Convert UNIX timestamp to readable time + tm := time.Unix(int64(position.GetTimestamp()), 0) + builder.WriteString(fmt.Sprintf(" Timestamp: %s\n", tm.Format(time.RFC3339))) + + if position.GetTimestampMillisAdjust() != 0 { + builder.WriteString(fmt.Sprintf(" Millis adjustment: %d\n", position.GetTimestampMillisAdjust())) + } + } + + // Source info + if position.GetLocationSource() != pb.Position_LOC_UNSET { + builder.WriteString(fmt.Sprintf(" Location Source: %s\n", position.GetLocationSource())) + } + + if position.GetAltitudeSource() != pb.Position_ALT_UNSET { + builder.WriteString(fmt.Sprintf(" Altitude Source: %s\n", position.GetAltitudeSource())) + } + + // GPS quality information if available + if position.GetPDOP() != 0 { + builder.WriteString(fmt.Sprintf(" PDOP: %.1f\n", float64(position.GetPDOP())/10)) + } + if position.GetHDOP() != 0 { + builder.WriteString(fmt.Sprintf(" HDOP: %.1f\n", float64(position.GetHDOP())/10)) + } + if position.GetVDOP() != 0 { + builder.WriteString(fmt.Sprintf(" VDOP: %.1f\n", float64(position.GetVDOP())/10)) + } + if position.GetGpsAccuracy() != 0 { + builder.WriteString(fmt.Sprintf(" GPS Accuracy: %d meters\n", position.GetGpsAccuracy())) + } + if position.GetGroundSpeed() != 0 { + builder.WriteString(fmt.Sprintf(" Ground Speed: %.1f m/s\n", float64(position.GetGroundSpeed())/100)) + } + if position.GetGroundTrack() != 0 { + builder.WriteString(fmt.Sprintf(" Ground Track: %d°\n", position.GetGroundTrack())) + } + if position.GetSatsInView() != 0 { + builder.WriteString(fmt.Sprintf(" Satellites in view: %d\n", position.GetSatsInView())) + } + + // Marshal to JSON for detailed view + jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(position) + if err == nil { + builder.WriteString("\nFull Position Structure:\n") + builder.WriteString(string(jsonBytes)) + } + + return builder.String() +} + +// FormatNodeInfoMessage formats a User message (used by the NODEINFO_APP port) +func FormatNodeInfoMessage(user *pb.User) string { + var builder strings.Builder + + if user == nil { + return "Error: nil user data" + } + + builder.WriteString("Node Information:\n") + + if user.GetId() != "" { + builder.WriteString(fmt.Sprintf(" ID: %s\n", user.GetId())) + } + + if user.GetLongName() != "" { + builder.WriteString(fmt.Sprintf(" Name: %s\n", user.GetLongName())) + } + + if user.GetShortName() != "" { + builder.WriteString(fmt.Sprintf(" Short Name: %s\n", user.GetShortName())) + } + + if user.GetHwModel() != pb.HardwareModel_UNSET { + builder.WriteString(fmt.Sprintf(" Hardware: %s\n", user.GetHwModel())) + } + + if user.GetIsLicensed() { + builder.WriteString(" Licensed HAM operator\n") + } + + if user.GetRole() != 0 { + builder.WriteString(fmt.Sprintf(" Role: %s\n", user.GetRole())) + } + + if len(user.GetPublicKey()) > 0 { + builder.WriteString(fmt.Sprintf(" Public Key: %x\n", user.GetPublicKey())) + } + + // Marshal to JSON for detailed view + jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(user) + if err == nil { + builder.WriteString("\nFull User Structure:\n") + builder.WriteString(string(jsonBytes)) + } + + return builder.String() +} + +// FormatWaypointMessage formats a Waypoint message +func FormatWaypointMessage(waypoint *pb.Waypoint) string { + var builder strings.Builder + + if waypoint == nil { + return "Error: nil waypoint data" + } + + builder.WriteString("Waypoint:\n") + + if waypoint.GetId() != 0 { + builder.WriteString(fmt.Sprintf(" ID: %d\n", waypoint.GetId())) + } + + if waypoint.GetName() != "" { + builder.WriteString(fmt.Sprintf(" Name: %s\n", waypoint.GetName())) + } + + if waypoint.GetDescription() != "" { + builder.WriteString(fmt.Sprintf(" Description: %s\n", waypoint.GetDescription())) + } + + if waypoint.GetLatitudeI() != 0 && waypoint.GetLongitudeI() != 0 { + // Convert the integer coordinates to floating-point degrees + lat := float64(waypoint.GetLatitudeI()) / 10000000.0 + lon := float64(waypoint.GetLongitudeI()) / 10000000.0 + + builder.WriteString(fmt.Sprintf(" Latitude: %.7f\n", lat)) + builder.WriteString(fmt.Sprintf(" Longitude: %.7f\n", lon)) + + // Google Maps link for convenience + builder.WriteString(fmt.Sprintf(" Google Maps: https://maps.google.com/?q=%.7f,%.7f\n", lat, lon)) + } + + if waypoint.GetIcon() != 0 { + builder.WriteString(fmt.Sprintf(" Icon: %d\n", waypoint.GetIcon())) + } + + if waypoint.GetExpire() != 0 { + // Convert UNIX timestamp to readable time + tm := time.Unix(int64(waypoint.GetExpire()), 0) + builder.WriteString(fmt.Sprintf(" Expires: %s\n", tm.Format(time.RFC3339))) + } + + if waypoint.GetLockedTo() != 0 { + builder.WriteString(fmt.Sprintf(" Locked to node: %d\n", waypoint.GetLockedTo())) + } + + // Marshal to JSON for detailed view + jsonBytes, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(waypoint) + if err == nil { + builder.WriteString("\nFull Waypoint Structure:\n") + builder.WriteString(string(jsonBytes)) + } + + return builder.String() +} + +// FormatData formats a Data message (deprecated - use FormatPayload instead) +func FormatData(data *pb.Data) string { + if data == nil { + return "Error: nil data" + } + + decoded := &DecodedPacket{ + PortNum: data.GetPortnum(), + RequestID: data.GetRequestId(), + ReplyID: data.GetReplyId(), + Emoji: data.GetEmoji(), + Dest: data.GetDest(), + Source: data.GetSource(), + WantResponse: data.GetWantResponse(), + RawData: data, + } + + // Process payload based on type + payload := data.GetPayload() + switch data.GetPortnum() { + case pb.PortNum_TEXT_MESSAGE_APP: + decoded.Payload = string(payload) + case pb.PortNum_TELEMETRY_APP: + var telemetry pb.Telemetry + if err := proto.Unmarshal(payload, &telemetry); err == nil { + decoded.Payload = &telemetry + } else { + decoded.Payload = payload + } + case pb.PortNum_POSITION_APP: + var position pb.Position + if err := proto.Unmarshal(payload, &position); err == nil { + decoded.Payload = &position + } else { + decoded.Payload = payload + } + case pb.PortNum_NODEINFO_APP: + var user pb.User + if err := proto.Unmarshal(payload, &user); err == nil { + decoded.Payload = &user + } else { + decoded.Payload = payload + } + case pb.PortNum_WAYPOINT_APP: + var waypoint pb.Waypoint + if err := proto.Unmarshal(payload, &waypoint); err == nil { + decoded.Payload = &waypoint + } else { + decoded.Payload = payload + } + default: + decoded.Payload = payload + } + + // Format with our new formatter + var builder strings.Builder + builder.WriteString(fmt.Sprintf("Data (Port: %s):\n", data.GetPortnum())) + builder.WriteString(FormatPayload(decoded.Payload, decoded.PortNum)) + + // Show additional Data fields + if data.GetRequestId() != 0 { + builder.WriteString(fmt.Sprintf(" Request ID: %d\n", data.GetRequestId())) + } + if data.GetReplyId() != 0 { + builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", data.GetReplyId())) + } + if data.GetEmoji() != 0 { + builder.WriteString(fmt.Sprintf(" Emoji: %d\n", data.GetEmoji())) + } + if data.GetDest() != 0 { + builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", data.GetDest())) + } + if data.GetSource() != 0 { + builder.WriteString(fmt.Sprintf(" Source Node: %d\n", data.GetSource())) + } + if data.GetWantResponse() { + builder.WriteString(" Wants Response: Yes\n") + } + + return builder.String() +} + +// FormatPayload formats a payload based on its type and port number +func FormatPayload(payload interface{}, portNum pb.PortNum) string { + if payload == nil { + return " No payload data\n" + } + + switch portNum { + case pb.PortNum_TEXT_MESSAGE_APP: + // Text message + if text, ok := payload.(string); ok { + return fmt.Sprintf(" Text Message: %s\n", text) + } + + case pb.PortNum_TEXT_MESSAGE_COMPRESSED_APP: + // Compressed text + if data, ok := payload.([]byte); ok { + return fmt.Sprintf(" Compressed Text Message (%d bytes): %x\n", len(data), data) + // TODO: Add decompression support + } + + case pb.PortNum_TELEMETRY_APP: + // Telemetry data + if telemetry, ok := payload.(*pb.Telemetry); ok { + return FormatTelemetryMessage(telemetry) + } + + case pb.PortNum_POSITION_APP: + // Position data + if position, ok := payload.(*pb.Position); ok { + return FormatPositionMessage(position) + } + + case pb.PortNum_NODEINFO_APP: + // Node information + if user, ok := payload.(*pb.User); ok { + return FormatNodeInfoMessage(user) + } + + case pb.PortNum_WAYPOINT_APP: + // Waypoint data + if waypoint, ok := payload.(*pb.Waypoint); ok { + return FormatWaypointMessage(waypoint) + } + } + + // Default formatting for unknown types + switch v := payload.(type) { + case string: + return fmt.Sprintf(" Text: %s\n", v) + case []byte: + result := fmt.Sprintf(" Binary (%d bytes): %x\n", len(v), v) + if IsASCII(v) { + result += fmt.Sprintf(" As text: %s\n", string(v)) + } + return result + default: + return fmt.Sprintf(" Payload of type %T\n", payload) + } +} + +// FormatDecodedPacket formats a DecodedPacket into a human-readable string +func FormatDecodedPacket(packet *DecodedPacket) string { + var builder strings.Builder + + if packet == nil { + return "Error: nil packet" + } + + if packet.DecodeError != nil { + builder.WriteString(fmt.Sprintf("Error decoding packet: %v\n", packet.DecodeError)) + return builder.String() + } + + // Envelope info + builder.WriteString("Packet:\n") + builder.WriteString(fmt.Sprintf(" Channel ID: %s\n", packet.ChannelID)) + if packet.GatewayID != "" { + builder.WriteString(fmt.Sprintf(" Gateway ID: %s\n", packet.GatewayID)) + } + + // Mesh packet info + builder.WriteString(fmt.Sprintf(" ID: %d\n", packet.ID)) + builder.WriteString(fmt.Sprintf(" From: %d\n", packet.From)) + builder.WriteString(fmt.Sprintf(" To: %d\n", packet.To)) + + // Additional fields that might be interesting + builder.WriteString(fmt.Sprintf(" Port: %s\n", packet.PortNum)) + + if packet.HopLimit != 0 { + builder.WriteString(fmt.Sprintf(" Hop Limit: %d\n", packet.HopLimit)) + } + if packet.HopStart != 0 { + builder.WriteString(fmt.Sprintf(" Hop Start: %d\n", packet.HopStart)) + } + if packet.WantACK { + builder.WriteString(" Wants ACK: Yes\n") + } + if packet.ViaMQTT { + builder.WriteString(" Via MQTT: Yes\n") + } + if packet.NextHop != 0 { + builder.WriteString(fmt.Sprintf(" Next Hop: %d\n", packet.NextHop)) + } + if packet.RelayNode != 0 { + builder.WriteString(fmt.Sprintf(" Relay Node: %d\n", packet.RelayNode)) + } + + // Additional data fields + if packet.RequestID != 0 { + builder.WriteString(fmt.Sprintf(" Request ID: %d\n", packet.RequestID)) + } + if packet.ReplyID != 0 { + builder.WriteString(fmt.Sprintf(" Reply ID: %d\n", packet.ReplyID)) + } + if packet.Emoji != 0 { + builder.WriteString(fmt.Sprintf(" Emoji: %d\n", packet.Emoji)) + } + if packet.Dest != 0 { + builder.WriteString(fmt.Sprintf(" Destination Node: %d\n", packet.Dest)) + } + if packet.Source != 0 { + builder.WriteString(fmt.Sprintf(" Source Node: %d\n", packet.Source)) + } + if packet.WantResponse { + builder.WriteString(" Wants Response: Yes\n") + } + + // Format the payload + builder.WriteString("\n") + builder.WriteString(FormatPayload(packet.Payload, packet.PortNum)) + + return builder.String() +} + +// FormatServiceEnvelope formats a ServiceEnvelope message into a human-readable string +func FormatServiceEnvelope(envelope *pb.ServiceEnvelope) string { + var builder strings.Builder + + builder.WriteString("ServiceEnvelope:\n") + + // Print basic envelope info + builder.WriteString(fmt.Sprintf(" Channel ID: %s\n", envelope.GetChannelId())) + builder.WriteString(fmt.Sprintf(" Gateway ID: %s\n", envelope.GetGatewayId())) + + // Print MeshPacket info if available + if packet := envelope.GetPacket(); packet != nil { + builder.WriteString("\nMeshPacket:\n") + builder.WriteString(fmt.Sprintf(" ID: %d\n", packet.GetId())) + builder.WriteString(fmt.Sprintf(" From: %d\n", packet.GetFrom())) + builder.WriteString(fmt.Sprintf(" To: %d\n", packet.GetTo())) + + // Output routing and hop information + builder.WriteString(fmt.Sprintf(" Hop Limit: %d\n", packet.GetHopLimit())) + builder.WriteString(fmt.Sprintf(" Hop Start: %d\n", packet.GetHopStart())) + builder.WriteString(fmt.Sprintf(" Want ACK: %v\n", packet.GetWantAck())) + builder.WriteString(fmt.Sprintf(" Priority: %s\n", packet.GetPriority())) + + // Output if the packet was delivered via MQTT + if packet.GetViaMqtt() { + builder.WriteString(" Via MQTT: Yes\n") + } + + // Relay and next hop info + if packet.GetNextHop() != 0 { + builder.WriteString(fmt.Sprintf(" Next Hop: %d\n", packet.GetNextHop())) + } + if packet.GetRelayNode() != 0 { + builder.WriteString(fmt.Sprintf(" Relay Node: %d\n", packet.GetRelayNode())) + } + + // Show public key information if available (for PKI-encrypted packets) + if len(packet.GetPublicKey()) > 0 { + builder.WriteString(fmt.Sprintf(" Public Key: %x\n", packet.GetPublicKey())) + builder.WriteString(fmt.Sprintf(" PKI Encrypted: %v\n", packet.GetPkiEncrypted())) + } + + // Determine payload type + if packet.GetDecoded() != nil { + // For already decoded packets, use our specialized data formatter + builder.WriteString("\n") + builder.WriteString(FormatData(packet.GetDecoded())) + + } else if packet.GetEncrypted() != nil { + // Encrypted payload information + builder.WriteString("\n Encrypted Payload:\n") + builder.WriteString(fmt.Sprintf(" Size: %d bytes\n", len(packet.GetEncrypted()))) + builder.WriteString(fmt.Sprintf(" Channel: %d\n", packet.GetChannel())) + + // Print the first few bytes of the encrypted payload for identification + if len(packet.GetEncrypted()) > 0 { + displayLen := len(packet.GetEncrypted()) + if displayLen > 16 { + displayLen = 16 + } + builder.WriteString(fmt.Sprintf(" First %d bytes: %x\n", displayLen, packet.GetEncrypted()[:displayLen])) + } + + // If the packet has channel ID, it's using channel-based encryption + channelId := envelope.GetChannelId() + if channelId != "" { + builder.WriteString(fmt.Sprintf(" Encryption: Channel-based (Channel ID: %s)\n", channelId)) + + // Attempt to decrypt the payload using the channel key + channelKey := GetChannelKey(channelId) + builder.WriteString(fmt.Sprintf(" Using key (%d bytes): %x\n", len(channelKey), channelKey)) + + // Try to decrypt + decrypted, err := XOR(packet.GetEncrypted(), channelKey, packet.GetId(), packet.GetFrom()) + if err != nil { + builder.WriteString(fmt.Sprintf(" Decryption error: %v\n", err)) + } else { + builder.WriteString(fmt.Sprintf(" Decrypted (%d bytes): %x\n", len(decrypted), decrypted)) + + // Try to parse the decrypted payload as a Data message + var data pb.Data + if err := proto.Unmarshal(decrypted, &data); err == nil { + // Successfully decoded the decrypted payload into a Data message + // Use our specialized data formatter to show the message details + builder.WriteString("\n") + builder.WriteString(indent(FormatData(&data), " ")) + } else { + // If we couldn't parse as Data, try to interpret as text + if IsASCII(decrypted) { + builder.WriteString(fmt.Sprintf(" Decrypted as text: %s\n", string(decrypted))) + } else { + builder.WriteString(fmt.Sprintf(" Failed to parse decrypted data as pb.Data: %v\n", err)) + } + } + } + } + } + } + + // Use protojson to generate a full JSON representation for debugging + marshaler := protojson.MarshalOptions{ + Multiline: true, + Indent: " ", + } + jsonBytes, err := marshaler.Marshal(envelope) + if err == nil { + builder.WriteString("\nFull Protobuf Structure:\n") + builder.WriteString(string(jsonBytes)) + } + + return builder.String() +} + +// indent adds indentation to each line of a string +func indent(s, prefix string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = prefix + line + } + } + return strings.Join(lines, "\n") +} + +// FormatJSONMessage formats a JSON message into a human-readable string +func FormatJSONMessage(jsonData map[string]interface{}) string { + var builder strings.Builder + + builder.WriteString("JSON Message:\n") + + // Extract and display common fields + if from, ok := jsonData["from"].(string); ok { + builder.WriteString(fmt.Sprintf(" From: %s\n", from)) + } + if to, ok := jsonData["to"].(string); ok { + builder.WriteString(fmt.Sprintf(" To: %s\n", to)) + } + if message, ok := jsonData["payload"].(string); ok { + builder.WriteString(fmt.Sprintf(" Message: %s\n", message)) + } + if timestamp, ok := jsonData["timestamp"].(string); ok { + builder.WriteString(fmt.Sprintf(" Timestamp: %s\n", timestamp)) + } + + // Format the full JSON for reference + jsonBytes, err := json.MarshalIndent(jsonData, " ", " ") + if err == nil { + builder.WriteString("\nFull JSON Structure:\n ") + builder.WriteString(string(jsonBytes)) + } + + return builder.String() +} + +// FormatMessage formats a decoded message based on its format +func FormatMessage(topicInfo *TopicInfo, payload []byte) string { + var builder strings.Builder + + // Display topic information + builder.WriteString(fmt.Sprintf("Topic: %s\n", topicInfo.FullTopic)) + builder.WriteString(fmt.Sprintf("Region Path: %s\n", topicInfo.RegionPath)) + builder.WriteString(fmt.Sprintf("Version: %s\n", topicInfo.Version)) + builder.WriteString(fmt.Sprintf("Format: %s\n", topicInfo.Format)) + builder.WriteString(fmt.Sprintf("Channel: %s\n", topicInfo.Channel)) + if topicInfo.UserID != "" { + builder.WriteString(fmt.Sprintf("User ID: %s\n", topicInfo.UserID)) + } + builder.WriteString("\n") + + // Decode and format based on the format + if topicInfo.Format == "e" { + // Encoded protobuf message - use our new decoder + decodedPacket := DecodeMessage(payload, topicInfo) + builder.WriteString(FormatDecodedPacket(decodedPacket)) + } else if topicInfo.Format == "json" { + // JSON message + jsonData, err := DecodeJSONMessage(payload) + if err != nil { + builder.WriteString(fmt.Sprintf("Error decoding JSON message: %v\n", err)) + builder.WriteString(fmt.Sprintf("Raw Data: %s\n", string(payload))) + } else { + builder.WriteString(FormatJSONMessage(jsonData)) + } + } else { + // Unknown format + builder.WriteString(fmt.Sprintf("Unsupported format: %s\n", topicInfo.Format)) + builder.WriteString(fmt.Sprintf("Raw Data (%d bytes): %x\n", len(payload), payload)) + } + + return builder.String() +} \ No newline at end of file diff --git a/decoder/keys.go b/decoder/keys.go index c97d0f2..c5d00d4 100644 --- a/decoder/keys.go +++ b/decoder/keys.go @@ -7,7 +7,7 @@ import ( ) // DefaultPrivateKey is the key used by pseudo public channels -const DefaultPrivateKey = "AQ==" +const DefaultPrivateKey = "1PG7OiApB1nwvP+rz05pAQ==" // ChannelKeys maps channelId to privateKey var channelKeys = make(map[string][]byte) @@ -19,13 +19,13 @@ func AddChannelKey(channelId, base64Key string) error { if err != nil { return fmt.Errorf("invalid base64 key: %v", err) } - + // Ensure the key is properly padded to be a valid AES key length key = PadKey(key) - + channelKeysMutex.Lock() defer channelKeysMutex.Unlock() - + channelKeys[channelId] = key return nil } @@ -34,11 +34,11 @@ func AddChannelKey(channelId, base64Key string) error { func GetChannelKey(channelId string) []byte { channelKeysMutex.RLock() defer channelKeysMutex.RUnlock() - + if key, ok := channelKeys[channelId]; ok { return key } - + // Return the default key if no specific key is found defaultKey, _ := base64.StdEncoding.DecodeString(DefaultPrivateKey) return PadKey(defaultKey) @@ -48,7 +48,7 @@ func GetChannelKey(channelId string) []byte { func ClearChannelKeys() { channelKeysMutex.Lock() defer channelKeysMutex.Unlock() - + channelKeys = make(map[string][]byte) } @@ -56,7 +56,7 @@ func ClearChannelKeys() { func ListChannelKeys() map[string]string { channelKeysMutex.RLock() defer channelKeysMutex.RUnlock() - + result := make(map[string]string) for id, key := range channelKeys { result[id] = base64.StdEncoding.EncodeToString(key) @@ -68,7 +68,7 @@ func ListChannelKeys() map[string]string { func RemoveChannelKey(channelId string) { channelKeysMutex.Lock() defer channelKeysMutex.Unlock() - + delete(channelKeys, channelId) } @@ -78,7 +78,7 @@ func PadKey(key []byte) []byte { if len(key) == 16 || len(key) == 24 || len(key) == 32 { return key } - + // Pad to the next valid AES key length if len(key) < 16 { paddedKey := make([]byte, 16) @@ -93,4 +93,4 @@ func PadKey(key []byte) []byte { copy(paddedKey, key) return paddedKey } -} \ No newline at end of file +} diff --git a/main.go b/main.go index d6273d9..9787bee 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ const ( mqttBroker = "mqtt.bayme.sh" mqttUsername = "meshdev" mqttPassword = "large4cats" - mqttTopicPrefix = "msh/US/CA/Motherlode" + mqttTopicPrefix = "msh/US" ) var messagePubHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) { diff --git a/meshstream b/meshstream new file mode 100755 index 0000000..193b59a Binary files /dev/null and b/meshstream differ