From 35ba0fbd004fd3f96489d7023baa195b0abfbfbf Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 3 Jun 2026 02:14:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E5=8C=85?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 56 +++++- mqtpp/mqtpp.go | 470 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 522 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6840f40..5cc368a 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,54 @@ go run . --topic 'msh/US/#' --topic 'msh/EU_868/#' | 绿色 | `nodeinfo` | `NODEINFO_APP` | 节点信息包,包含节点 ID、长名称、短名称、硬件型号、角色、公钥等 | | 蓝色 | `map_report` | `MAP_REPORT_APP` | 地图报告包,包含节点名称、硬件、固件版本、区域、调制预设、位置等地图信息 | | 紫色 | `text_message` | `TEXT_MESSAGE_APP` | 聊天文本消息 | -| 青色 | `position` | `POSITION_APP` | 位置包,表示节点位置相关数据;当前只标记类型,不展开解析 payload | -| 黄色 | `telemetry` | `TELEMETRY_APP` | 遥测包,表示电池、信道、设备或环境传感器相关数据;当前只标记类型,不展开解析 payload | +| 青色 | `position` | `POSITION_APP` | 位置包,会展开解析经纬度、海拔、时间、定位来源、精度、速度、卫星数等字段 | +| 黄色 | `telemetry` | `TELEMETRY_APP` | 遥测包,会展开解析设备、电源、环境、空气质量、本地统计、健康、主机和流量管理指标 | | 灰色 | `routing` | `ROUTING_APP` | 路由控制包,常见于 ACK、NAK、路由错误等控制信息 | | 灰色 | `traceroute` | `TRACEROUTE_APP` | 路径追踪包,用于 mesh 网络路径探测 | | 红色 | error record | - | protobuf 解析失败、payload 解码失败或其他处理错误 | | 无颜色 | `encrypted_packet` | - | 加密包但当前 PSK/频道 hash 无法解密;这不一定是错误 | | 无颜色 | `decoded_packet` | 其他 portnum | 已解码/已解密,但程序尚未细分的其他应用包 | +## 已展开解析的数据包 + +### `position` / `POSITION_APP` + +位置包会从 Meshtastic `Position` payload 中展开常用字段,包括: + +- `latitude` / `longitude`:经纬度,已从 `latitude_i` / `longitude_i` 转换为浮点角度 +- `altitude`:海拔,单位米 +- `time` / `timestamp`:位置相关时间戳 +- `location_source`:定位来源,例如 `LOC_MANUAL`、`LOC_INTERNAL`、`LOC_EXTERNAL` +- `altitude_source`:海拔来源,例如 `ALT_MANUAL`、`ALT_INTERNAL`、`ALT_BAROMETRIC` +- `altitude_hae` / `altitude_geoidal_separation`:HAE 海拔和大地水准面分离值 +- `pdop` / `hdop` / `vdop`:定位精度因子,已从 1/100 单位转换为浮点值 +- `gps_accuracy`:GPS 精度,单位 mm +- `ground_speed`:地面速度,单位 m/s +- `ground_track`:地面航迹角,已从 1/100 度转换为度 +- `fix_quality` / `fix_type` / `sats_in_view`:GPS fix 质量、类型和可见卫星数 +- `sensor_id` / `next_update` / `seq_number` / `precision_bits`:传感器、更新间隔、序列号和位置精度位数 + +### `telemetry` / `TELEMETRY_APP` + +遥测包会输出: + +- `time`:遥测时间戳 +- `telemetry_type`:具体 telemetry variant +- `metrics`:展开后的指标对象 + +当前支持的 `telemetry_type`: + +| telemetry_type | 含义 | 常见 metrics | +|---|---|---| +| `device_metrics` | 设备状态 | `battery_level`、`voltage`、`channel_utilization`、`air_util_tx`、`uptime_seconds` | +| `environment_metrics` | 环境传感器 | `temperature`、`relative_humidity`、`barometric_pressure`、`gas_resistance`、`lux`、`wind_speed`、`rainfall_1h` 等 | +| `air_quality_metrics` | 空气质量 | `pm25_standard`、`pm100_standard`、`co2`、`pm_temperature`、`pm_humidity`、`pm_voc_idx` 等 | +| `power_metrics` | 多通道电源数据 | `ch1_voltage`、`ch1_current` 到 `ch8_voltage`、`ch8_current` | +| `local_stats` | 本地 mesh 统计 | `num_packets_tx`、`num_packets_rx`、`num_online_nodes`、`heap_free_bytes`、`noise_floor` 等 | +| `health_metrics` | 健康数据 | `heart_bpm`、`spO2`、`temperature` | +| `host_metrics` | Linux/Portduino 主机指标 | `uptime_seconds`、`freemem_bytes`、`diskfree1_bytes`、`load1`、`load5`、`load15`、`user_string` | +| `traffic_management_stats` | 流量管理统计 | `packets_inspected`、`position_dedup_drops`、`rate_limit_drops`、`unknown_packet_drops` 等 | + ## 过滤规则 程序默认不显示 `empty_packet`。 @@ -86,6 +126,18 @@ go run . --topic 'msh/US/#' --topic 'msh/EU_868/#' {"type":"text_message","portnum":"TEXT_MESSAGE_APP","from":"!12345678","text":"hello mesh"} ``` +位置包: + +```json +{"type":"position","portnum":"POSITION_APP","from":"!12345678","latitude":42.51043,"longitude":-83.08625,"altitude":192,"location_source":"LOC_INTERNAL","sats_in_view":8} +``` + +遥测包: + +```json +{"type":"telemetry","portnum":"TELEMETRY_APP","from":"!12345678","telemetry_type":"device_metrics","metrics":{"battery_level":85,"voltage":4.1,"channel_utilization":2.3,"air_util_tx":0.5,"uptime_seconds":12345}} +``` + 解密失败的加密包: ```json diff --git a/mqtpp/mqtpp.go b/mqtpp/mqtpp.go index 6273154..157e055 100644 --- a/mqtpp/mqtpp.go +++ b/mqtpp/mqtpp.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math" "unicode/utf8" "google.golang.org/protobuf/encoding/protowire" @@ -80,6 +81,38 @@ type mapReport struct { HasOptedReportLocation bool } +type positionInfo struct { + LatitudeI *int32 + LongitudeI *int32 + Altitude *int32 + Time uint32 + LocationSource uint64 + AltitudeSource uint64 + Timestamp uint32 + TimestampMillisAdjust int32 + AltitudeHAE *int32 + AltitudeGeoidalSeparation *int32 + PDOP uint32 + HDOP uint32 + VDOP uint32 + GPSAccuracy uint32 + GroundSpeed *uint32 + GroundTrack *uint32 + FixQuality uint32 + FixType uint32 + SatsInView uint32 + SensorID uint32 + NextUpdate uint32 + SeqNumber uint32 + PrecisionBits uint32 +} + +type telemetryInfo struct { + Time uint32 + Type string + Metrics map[string]any +} + // MQTTPP 处理一个 MQTT 原始 payload,返回合规状态、原始数据和解码后的 JSON。 // 第一个返回值表示数据是否合规;第二个返回值在不合规时为 nil;第三个返回值是解码结果 JSON。 func MQTTPP(topic string, raw []byte, key []byte) (bool, []byte, []byte) { @@ -315,6 +348,243 @@ func parseMapReport(payload []byte) (*mapReport, error) { return report, err } +// parsePosition 解析 POSITION_APP 的 Position payload。 +func parsePosition(payload []byte) (*positionInfo, error) { + position := &positionInfo{} + err := walkFields(payload, func(num protowire.Number, typ protowire.Type, value any) error { + switch num { + case 1: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + position.LatitudeI = int32Ptr(int32(v)) + } + case 2: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + position.LongitudeI = int32Ptr(int32(v)) + } + case 3: + if typ == protowire.VarintType { + position.Altitude = int32Ptr(int32(varintValue(typ, value))) + } + case 4: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + position.Time = v + } + case 5: + position.LocationSource = varintValue(typ, value) + case 6: + position.AltitudeSource = varintValue(typ, value) + case 7: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + position.Timestamp = v + } + case 8: + if typ == protowire.VarintType { + position.TimestampMillisAdjust = int32(varintValue(typ, value)) + } + case 9: + if typ == protowire.VarintType { + position.AltitudeHAE = int32Ptr(decodeZigZag32(varintValue(typ, value))) + } + case 10: + if typ == protowire.VarintType { + position.AltitudeGeoidalSeparation = int32Ptr(decodeZigZag32(varintValue(typ, value))) + } + case 11: + position.PDOP = uint32(varintValue(typ, value)) + case 12: + position.HDOP = uint32(varintValue(typ, value)) + case 13: + position.VDOP = uint32(varintValue(typ, value)) + case 14: + position.GPSAccuracy = uint32(varintValue(typ, value)) + case 15: + position.GroundSpeed = uint32Ptr(uint32(varintValue(typ, value))) + case 16: + position.GroundTrack = uint32Ptr(uint32(varintValue(typ, value))) + case 17: + position.FixQuality = uint32(varintValue(typ, value)) + case 18: + position.FixType = uint32(varintValue(typ, value)) + case 19: + position.SatsInView = uint32(varintValue(typ, value)) + case 20: + position.SensorID = uint32(varintValue(typ, value)) + case 21: + position.NextUpdate = uint32(varintValue(typ, value)) + case 22: + position.SeqNumber = uint32(varintValue(typ, value)) + case 23: + position.PrecisionBits = uint32(varintValue(typ, value)) + } + return nil + }) + return position, err +} + +// parseTelemetry 解析 TELEMETRY_APP 的 Telemetry payload 和具体 telemetry variant。 +func parseTelemetry(payload []byte) (*telemetryInfo, error) { + telemetry := &telemetryInfo{} + err := walkFields(payload, func(num protowire.Number, typ protowire.Type, value any) error { + switch num { + case 1: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + telemetry.Time = v + } + case 2: + telemetry.Type = "device_metrics" + telemetry.Metrics = parseMetricBytes(typ, value, deviceMetricFields) + case 3: + telemetry.Type = "environment_metrics" + telemetry.Metrics = parseMetricBytes(typ, value, environmentMetricFields) + case 4: + telemetry.Type = "air_quality_metrics" + telemetry.Metrics = parseMetricBytes(typ, value, airQualityMetricFields) + case 5: + telemetry.Type = "power_metrics" + telemetry.Metrics = parseMetricBytes(typ, value, powerMetricFields) + case 6: + telemetry.Type = "local_stats" + telemetry.Metrics = parseMetricBytes(typ, value, localStatsFields) + case 7: + telemetry.Type = "health_metrics" + telemetry.Metrics = parseMetricBytes(typ, value, healthMetricFields) + case 8: + telemetry.Type = "host_metrics" + telemetry.Metrics = parseMetricBytes(typ, value, hostMetricFields) + case 9: + telemetry.Type = "traffic_management_stats" + telemetry.Metrics = parseMetricBytes(typ, value, trafficManagementFields) + } + return nil + }) + return telemetry, err +} + +type metricKind int + +const ( + metricUint32 metricKind = iota + metricUint64 + metricInt32 + metricFloat32 + metricString + metricRepeatedFloat32 +) + +type metricField struct { + Name string + Kind metricKind +} + +// parseMetricBytes 按字段定义表解析 telemetry variant 的指标字段。 +func parseMetricBytes(typ protowire.Type, value any, fields map[protowire.Number]metricField) map[string]any { + metrics := map[string]any{} + payload, ok := value.([]byte) + if !ok || typ != protowire.BytesType { + return metrics + } + _ = walkFields(payload, func(num protowire.Number, typ protowire.Type, value any) error { + field, ok := fields[num] + if !ok { + return nil + } + switch field.Kind { + case metricUint32: + metrics[field.Name] = uint32(varintValue(typ, value)) + case metricUint64: + metrics[field.Name] = varintValue(typ, value) + case metricInt32: + metrics[field.Name] = int32(varintValue(typ, value)) + case metricFloat32: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + metrics[field.Name] = float64(math.Float32frombits(v)) + } + case metricString: + metrics[field.Name] = stringBytes(typ, value) + case metricRepeatedFloat32: + if v, ok := value.(uint32); ok && typ == protowire.Fixed32Type { + appendMetric(metrics, field.Name, float64(math.Float32frombits(v))) + } + if payload, ok := value.([]byte); ok && typ == protowire.BytesType { + for len(payload) > 0 { + v, n := protowire.ConsumeFixed32(payload) + if n < 0 { + break + } + appendMetric(metrics, field.Name, float64(math.Float32frombits(v))) + payload = payload[n:] + } + } + } + return nil + }) + return metrics +} + +// appendMetric 追加 repeated telemetry 字段值。 +func appendMetric(metrics map[string]any, name string, value any) { + if existing, ok := metrics[name]; ok { + metrics[name] = append(existing.([]any), value) + return + } + metrics[name] = []any{value} +} + +// int32Ptr 返回 int32 指针,用于记录 proto optional 字段是否出现。 +func int32Ptr(v int32) *int32 { + return &v +} + +// uint32Ptr 返回 uint32 指针,用于记录 proto optional 字段是否出现。 +func uint32Ptr(v uint32) *uint32 { + return &v +} + +// decodeZigZag32 解码 protobuf sint32 的 zig-zag 编码。 +func decodeZigZag32(v uint64) int32 { + return int32((v >> 1) ^ uint64(-int64(v&1))) +} + +// optionalInt32 把 optional int32 指针转换成 JSON 可表达的值。 +func optionalInt32(v *int32) any { + if v == nil { + return nil + } + return *v +} + +// optionalUint32 把 optional uint32 指针转换成 JSON 可表达的值。 +func optionalUint32(v *uint32) any { + if v == nil { + return nil + } + return *v +} + +// optionalCoordinate 把 Meshtastic 1e-7 度坐标转换成浮点经纬度。 +func optionalCoordinate(v *int32) any { + if v == nil { + return nil + } + return float64(*v) * 1e-7 +} + +// optionalDegrees100 把 1/100 度单位转换成度。 +func optionalDegrees100(v *uint32) any { + if v == nil { + return nil + } + return float64(*v) / 100 +} + +// dopValue 把 1/100 精度因子转换成浮点值,未设置时返回 nil。 +func dopValue(v uint32) any { + if v == 0 { + return nil + } + return float64(v) / 100 +} + // walkFields 遍历 protobuf wire 字段,并把字段号、类型和值交给回调处理。 func walkFields(payload []byte, handle func(protowire.Number, protowire.Type, any) error) error { for len(payload) > 0 { @@ -454,9 +724,17 @@ func describePacket(topic string, env *serviceEnvelope, key []byte) (map[string] case textMessageApp: return merge(decodedBase, decodeTextMessage(packet)), nil case positionApp: - return merge(decodedBase, map[string]any{"type": "position"}), nil + record, err := decodePosition(packet) + if err != nil { + return nil, err + } + return merge(decodedBase, record), nil case telemetryApp: - return merge(decodedBase, map[string]any{"type": "telemetry"}), nil + record, err := decodeTelemetry(packet) + if err != nil { + return nil, err + } + return merge(decodedBase, record), nil case routingApp: return merge(decodedBase, map[string]any{"type": "routing"}), nil case tracerouteApp: @@ -555,6 +833,60 @@ func decodeMapReport(packet *meshPacket) (map[string]any, error) { }, nil } +// decodePosition 将 POSITION_APP payload 解码为位置 JSON 字段。 +func decodePosition(packet *meshPacket) (map[string]any, error) { + position, err := parsePosition(packet.Decoded.Payload) + if err != nil { + return nil, err + } + + return map[string]any{ + "type": "position", + "from": nodeNumToID(packet.From), + "from_num": packet.From, + "latitude": optionalCoordinate(position.LatitudeI), + "longitude": optionalCoordinate(position.LongitudeI), + "altitude": optionalInt32(position.Altitude), + "time": position.Time, + "location_source": enumName(locationSourceNames, position.LocationSource), + "altitude_source": enumName(altitudeSourceNames, position.AltitudeSource), + "timestamp": position.Timestamp, + "timestamp_millis_adjust": position.TimestampMillisAdjust, + "altitude_hae": optionalInt32(position.AltitudeHAE), + "altitude_geoidal_separation": optionalInt32(position.AltitudeGeoidalSeparation), + "pdop": dopValue(position.PDOP), + "hdop": dopValue(position.HDOP), + "vdop": dopValue(position.VDOP), + "gps_accuracy": position.GPSAccuracy, + "ground_speed": optionalUint32(position.GroundSpeed), + "ground_track": optionalDegrees100(position.GroundTrack), + "fix_quality": position.FixQuality, + "fix_type": position.FixType, + "sats_in_view": position.SatsInView, + "sensor_id": position.SensorID, + "next_update": position.NextUpdate, + "seq_number": position.SeqNumber, + "precision_bits": position.PrecisionBits, + }, nil +} + +// decodeTelemetry 将 TELEMETRY_APP payload 解码为遥测 JSON 字段。 +func decodeTelemetry(packet *meshPacket) (map[string]any, error) { + telemetry, err := parseTelemetry(packet.Decoded.Payload) + if err != nil { + return nil, err + } + + return map[string]any{ + "type": "telemetry", + "from": nodeNumToID(packet.From), + "from_num": packet.From, + "time": telemetry.Time, + "telemetry_type": telemetry.Type, + "metrics": telemetry.Metrics, + }, nil +} + // decodeTextMessage 将 TEXT_MESSAGE_APP payload 解码为聊天文本 JSON 字段。 func decodeTextMessage(packet *meshPacket) map[string]any { text := string(packet.Decoded.Payload) @@ -624,6 +956,140 @@ func enumName(names map[uint64]string, value uint64) any { return value } +var locationSourceNames = map[uint64]string{ + 0: "LOC_UNSET", + 1: "LOC_MANUAL", + 2: "LOC_INTERNAL", + 3: "LOC_EXTERNAL", +} + +var altitudeSourceNames = map[uint64]string{ + 0: "ALT_UNSET", + 1: "ALT_MANUAL", + 2: "ALT_INTERNAL", + 3: "ALT_EXTERNAL", + 4: "ALT_BAROMETRIC", +} + +var deviceMetricFields = map[protowire.Number]metricField{ + 1: {"battery_level", metricUint32}, + 2: {"voltage", metricFloat32}, + 3: {"channel_utilization", metricFloat32}, + 4: {"air_util_tx", metricFloat32}, + 5: {"uptime_seconds", metricUint32}, +} + +var environmentMetricFields = map[protowire.Number]metricField{ + 1: {"temperature", metricFloat32}, + 2: {"relative_humidity", metricFloat32}, + 3: {"barometric_pressure", metricFloat32}, + 4: {"gas_resistance", metricFloat32}, + 5: {"voltage", metricFloat32}, + 6: {"current", metricFloat32}, + 7: {"iaq", metricUint32}, + 8: {"distance", metricFloat32}, + 9: {"lux", metricFloat32}, + 10: {"white_lux", metricFloat32}, + 11: {"ir_lux", metricFloat32}, + 12: {"uv_lux", metricFloat32}, + 13: {"wind_direction", metricUint32}, + 14: {"wind_speed", metricFloat32}, + 15: {"weight", metricFloat32}, + 16: {"wind_gust", metricFloat32}, + 17: {"wind_lull", metricFloat32}, + 18: {"radiation", metricFloat32}, + 19: {"rainfall_1h", metricFloat32}, + 20: {"rainfall_24h", metricFloat32}, + 21: {"soil_moisture", metricUint32}, + 22: {"soil_temperature", metricFloat32}, + 23: {"one_wire_temperature", metricRepeatedFloat32}, +} + +var airQualityMetricFields = map[protowire.Number]metricField{ + 1: {"pm10_standard", metricUint32}, + 2: {"pm25_standard", metricUint32}, + 3: {"pm100_standard", metricUint32}, + 4: {"pm10_environmental", metricUint32}, + 5: {"pm25_environmental", metricUint32}, + 6: {"pm100_environmental", metricUint32}, + 7: {"particles_03um", metricUint32}, + 8: {"particles_05um", metricUint32}, + 9: {"particles_10um", metricUint32}, + 10: {"particles_25um", metricUint32}, + 11: {"particles_50um", metricUint32}, + 12: {"particles_100um", metricUint32}, + 13: {"co2", metricUint32}, + 14: {"co2_temperature", metricFloat32}, + 15: {"co2_humidity", metricFloat32}, + 16: {"form_formaldehyde", metricFloat32}, + 17: {"form_humidity", metricFloat32}, + 18: {"form_temperature", metricFloat32}, + 19: {"pm40_standard", metricUint32}, + 20: {"particles_40um", metricUint32}, + 21: {"pm_temperature", metricFloat32}, + 22: {"pm_humidity", metricFloat32}, + 23: {"pm_voc_idx", metricFloat32}, + 24: {"pm_nox_idx", metricFloat32}, + 25: {"particles_tps", metricFloat32}, +} + +var powerMetricFields = map[protowire.Number]metricField{ + 1: {"ch1_voltage", metricFloat32}, 2: {"ch1_current", metricFloat32}, + 3: {"ch2_voltage", metricFloat32}, 4: {"ch2_current", metricFloat32}, + 5: {"ch3_voltage", metricFloat32}, 6: {"ch3_current", metricFloat32}, + 7: {"ch4_voltage", metricFloat32}, 8: {"ch4_current", metricFloat32}, + 9: {"ch5_voltage", metricFloat32}, 10: {"ch5_current", metricFloat32}, + 11: {"ch6_voltage", metricFloat32}, 12: {"ch6_current", metricFloat32}, + 13: {"ch7_voltage", metricFloat32}, 14: {"ch7_current", metricFloat32}, + 15: {"ch8_voltage", metricFloat32}, 16: {"ch8_current", metricFloat32}, +} + +var localStatsFields = map[protowire.Number]metricField{ + 1: {"uptime_seconds", metricUint32}, + 2: {"channel_utilization", metricFloat32}, + 3: {"air_util_tx", metricFloat32}, + 4: {"num_packets_tx", metricUint32}, + 5: {"num_packets_rx", metricUint32}, + 6: {"num_packets_rx_bad", metricUint32}, + 7: {"num_online_nodes", metricUint32}, + 8: {"num_total_nodes", metricUint32}, + 9: {"num_rx_dupe", metricUint32}, + 10: {"num_tx_relay", metricUint32}, + 11: {"num_tx_relay_canceled", metricUint32}, + 12: {"heap_total_bytes", metricUint32}, + 13: {"heap_free_bytes", metricUint32}, + 14: {"num_tx_dropped", metricUint32}, + 15: {"noise_floor", metricInt32}, +} + +var healthMetricFields = map[protowire.Number]metricField{ + 1: {"heart_bpm", metricUint32}, + 2: {"spO2", metricUint32}, + 3: {"temperature", metricFloat32}, +} + +var hostMetricFields = map[protowire.Number]metricField{ + 1: {"uptime_seconds", metricUint32}, + 2: {"freemem_bytes", metricUint64}, + 3: {"diskfree1_bytes", metricUint64}, + 4: {"diskfree2_bytes", metricUint64}, + 5: {"diskfree3_bytes", metricUint64}, + 6: {"load1", metricUint32}, + 7: {"load5", metricUint32}, + 8: {"load15", metricUint32}, + 9: {"user_string", metricString}, +} + +var trafficManagementFields = map[protowire.Number]metricField{ + 1: {"packets_inspected", metricUint32}, + 2: {"position_dedup_drops", metricUint32}, + 3: {"nodeinfo_cache_hits", metricUint32}, + 4: {"rate_limit_drops", metricUint32}, + 5: {"unknown_packet_drops", metricUint32}, + 6: {"hop_exhausted_packets", metricUint32}, + 7: {"router_hops_preserved", metricUint32}, +} + var portNumNames = map[uint64]string{ 0: "UNKNOWN_APP", 1: "TEXT_MESSAGE_APP", 2: "REMOTE_HARDWARE_APP", 3: "POSITION_APP", 4: "NODEINFO_APP", 5: "ROUTING_APP", 6: "ADMIN_APP", 7: "TEXT_MESSAGE_COMPRESSED_APP", 8: "WAYPOINT_APP", 9: "AUDIO_APP",