use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawEvent {
#[serde(rename = "type")]
pub event_type: String,
pub ts_ms: i64,
pub data: Value,
}
pub const TYPE_METRICS_ROLLUP: &str = "mesh_metrics_rollup";
pub const TYPE_SESSION_SUMMARY: &str = "mesh_session_summary";
pub const TYPE_MESSAGE_DELIVERED: &str = "protocol.message.delivered";
pub const TYPE_MESSAGE_FAILED: &str = "protocol.message.failed";
pub const TYPE_MESSAGE_DEFERRED: &str = "protocol.message.deferred";
pub const TYPE_MESSAGE_RELAYED: &str = "protocol.message.relayed";
pub const TYPE_RELAY_PROMOTED: &str = "protocol.relay.promoted";
pub const TYPE_RELAY_DEMOTED: &str = "protocol.relay.demoted";
pub const TYPE_RELAY_DEMOTED_BATTERY: &str = "protocol.relay.demoted_battery";
pub const TYPE_ROUTING_DECISION: &str = "protocol.routing.decision";
pub const TYPE_TRANSPORT_STATE: &str = "protocol.transport.state_changed";
pub const TYPE_DEVICE_CAPABILITY: &str = "protocol.device.capability_changed";
pub const TYPE_MLS_DECRYPTION_FAILED: &str = "mls.decryption_failed";
pub const TYPE_MLS_SESSION_MISSING: &str = "mls.session_missing";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransportTimeMs {
pub ble: Option<i32>,
pub wifi_direct: Option<i32>,
pub internet: Option<i32>,
pub none: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EscalationReasons {
pub poor_signal: Option<i32>,
pub low_success_rate: Option<i32>,
pub retry_threshold: Option<i32>,
pub congestion: Option<i32>,
pub low_ttl: Option<i32>,
pub current_unavailable: Option<i32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MetricsRollupData {
pub bucket_duration_s: Option<i16>,
pub neighbor_count_avg: Option<f32>,
pub neighbor_count_max: Option<i32>,
pub neighbor_count_p50: Option<i32>,
pub ack_pending_avg: Option<f32>,
pub ack_pending_max: Option<i32>,
pub retry_queue_total_avg: Option<f32>,
pub retry_queue_total_max: Option<i32>,
#[serde(rename = "retry_queue_critical_count_max")]
pub retry_queue_critical_max: Option<i32>,
pub transport_time_ms: Option<TransportTimeMs>,
pub is_local_relay_duration_s: Option<i32>,
pub current_transport_at_bucket_end: Option<String>,
#[serde(rename = "sends_attempted_sum")]
pub sends_attempted: Option<i32>,
#[serde(rename = "sends_succeeded_sum")]
pub sends_succeeded: Option<i32>,
#[serde(rename = "sends_failed_sum")]
pub sends_failed: Option<i32>,
#[serde(rename = "bytes_sent_sum")]
pub bytes_sent: Option<i64>,
#[serde(rename = "bytes_received_sum")]
pub bytes_received: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessageDeliveredData {
pub latency_ms: Option<i32>,
pub hop_count: Option<i16>,
pub transport: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReasonRetryData {
pub reason_class: Option<String>,
pub retry_count: Option<i16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessageRelayedData {
pub hop_count: Option<i16>,
pub remaining_ttl: Option<i16>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelayPromotedData {
pub connection_count: Option<i16>,
pub battery_level: Option<i16>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelayDemotedData {
pub reason_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RelayDemotedBatteryData {
pub battery_level: Option<i16>,
pub min_required: Option<i16>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoutingDecisionData {
pub phase: Option<String>,
#[serde(rename = "from")]
pub from_transport: Option<String>,
#[serde(rename = "to")]
pub to_transport: Option<String>,
pub winning_score: Option<f32>,
pub reason_code: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TransportStateData {
pub transport: Option<String>,
#[serde(rename = "previous")]
pub previous_status: Option<String>,
#[serde(rename = "current")]
pub current_status: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DeviceCapabilityData {
pub battery_level: Option<i16>,
pub is_charging: Option<bool>,
pub relay_role: Option<String>,
pub changed_fields: Option<i16>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MlsDecryptionFailedData {
pub failure_kind: Option<String>,
#[serde(rename = "context")]
pub mls_context: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MlsSessionMissingData {
#[serde(rename = "context")]
pub mls_context: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionSummaryData {
pub session_duration_s: Option<i32>,
pub delivery_latency_p50_ms: Option<i32>,
pub delivery_latency_p99_ms: Option<i32>,
pub messages_sent: Option<i32>,
pub messages_delivered: Option<i32>,
pub messages_failed: Option<i32>,
pub transport_time_ms: Option<TransportTimeMs>,
pub routing_switches: Option<i32>,
pub routing_escalations: Option<i32>,
pub escalation_reasons: Option<EscalationReasons>,
pub mls_decryption_failures_by_kind: Option<Value>,
pub mls_session_ready_latency_p50_ms: Option<i32>,
pub mls_encryption_used_count: Option<i32>,
pub relay_duration_s: Option<i32>,
pub relay_battery_at_demote: Option<Vec<i16>>,
pub battery_start: Option<i16>,
pub battery_end: Option<i16>,
}
#[derive(Debug, Clone)]
pub enum Event {
MetricsRollup(MetricsRollupData),
MessageDelivered(MessageDeliveredData),
MessageFailed(ReasonRetryData),
MessageDeferred(ReasonRetryData),
MessageRelayed(MessageRelayedData),
RelayPromoted(RelayPromotedData),
RelayDemoted(RelayDemotedData),
RelayDemotedBattery(RelayDemotedBatteryData),
RoutingDecision(RoutingDecisionData),
TransportState(TransportStateData),
DeviceCapability(DeviceCapabilityData),
MlsDecryptionFailed(MlsDecryptionFailedData),
MlsSessionMissing(MlsSessionMissingData),
SessionSummary(SessionSummaryData),
Extension {
ext_name: String,
payload: Value,
},
}
fn parse<T: DeserializeOwned>(event_type: &str, data: &Value) -> Result<T, String> {
T::deserialize(data).map_err(|e| format!("{event_type}: {e}"))
}
pub fn classify(event_type: &str, data: &Value) -> Result<Event, String> {
Ok(match event_type {
TYPE_METRICS_ROLLUP => Event::MetricsRollup(parse(event_type, data)?),
TYPE_SESSION_SUMMARY => Event::SessionSummary(parse(event_type, data)?),
TYPE_MESSAGE_DELIVERED => Event::MessageDelivered(parse(event_type, data)?),
TYPE_MESSAGE_FAILED => Event::MessageFailed(parse(event_type, data)?),
TYPE_MESSAGE_DEFERRED => Event::MessageDeferred(parse(event_type, data)?),
TYPE_MESSAGE_RELAYED => Event::MessageRelayed(parse(event_type, data)?),
TYPE_RELAY_PROMOTED => Event::RelayPromoted(parse(event_type, data)?),
TYPE_RELAY_DEMOTED => Event::RelayDemoted(parse(event_type, data)?),
TYPE_RELAY_DEMOTED_BATTERY => Event::RelayDemotedBattery(parse(event_type, data)?),
TYPE_ROUTING_DECISION => Event::RoutingDecision(parse(event_type, data)?),
TYPE_TRANSPORT_STATE => Event::TransportState(parse(event_type, data)?),
TYPE_DEVICE_CAPABILITY => Event::DeviceCapability(parse(event_type, data)?),
TYPE_MLS_DECRYPTION_FAILED => Event::MlsDecryptionFailed(parse(event_type, data)?),
TYPE_MLS_SESSION_MISSING => Event::MlsSessionMissing(parse(event_type, data)?),
other => Event::Extension {
ext_name: other.to_string(),
payload: data.clone(),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn classifies_metrics_rollup_with_renames_and_nesting() {
let data = json!({
"bucket_start_ms": 1_747_000_000_000_i64,
"bucket_duration_s": 60,
"neighbor_count_avg": 4.5,
"neighbor_count_max": 9,
"neighbor_count_p50": 4,
"ack_pending_avg": 1.25,
"ack_pending_max": 3,
"retry_queue_total_avg": 2.0,
"retry_queue_total_max": 5,
"retry_queue_critical_count_max": 1,
"transport_time_ms": { "ble": 40000, "wifi_direct": 15000, "internet": 5000, "none": 0 },
"is_local_relay_duration_s": 12,
"current_transport_at_bucket_end": "ble",
"sends_attempted_sum": 100,
"sends_succeeded_sum": 98,
"sends_failed_sum": 2,
"bytes_sent_sum": 5_000_000_000_i64,
"bytes_received_sum": 6_000_000_000_i64
});
let Event::MetricsRollup(d) = classify(TYPE_METRICS_ROLLUP, &data).unwrap() else {
panic!("expected MetricsRollup");
};
assert_eq!(d.retry_queue_critical_max, Some(1));
assert_eq!(d.sends_attempted, Some(100));
assert_eq!(d.bytes_received, Some(6_000_000_000));
let t = d.transport_time_ms.unwrap();
assert_eq!(t.ble, Some(40000));
assert_eq!(t.none, Some(0));
assert_eq!(d.current_transport_at_bucket_end.as_deref(), Some("ble"));
}
#[test]
fn classifies_routing_decision_from_to_renames() {
let data = json!({
"phase": "select",
"from": "ble",
"to": "internet",
"winning_score": 0.87,
"reason_code": "poor_signal",
"scores": [0.1, 0.87] });
let Event::RoutingDecision(d) = classify(TYPE_ROUTING_DECISION, &data).unwrap() else {
panic!("expected RoutingDecision");
};
assert_eq!(d.from_transport.as_deref(), Some("ble"));
assert_eq!(d.to_transport.as_deref(), Some("internet"));
}
#[test]
fn classifies_mls_context_rename() {
let data = json!({ "context": "receive" });
let Event::MlsSessionMissing(d) = classify(TYPE_MLS_SESSION_MISSING, &data).unwrap() else {
panic!("expected MlsSessionMissing");
};
assert_eq!(d.mls_context.as_deref(), Some("receive"));
}
#[test]
fn transport_state_status_renames() {
let data = json!({ "transport": "ble", "previous": "available", "current": "unavailable" });
let Event::TransportState(d) = classify(TYPE_TRANSPORT_STATE, &data).unwrap() else {
panic!("expected TransportState");
};
assert_eq!(d.previous_status.as_deref(), Some("available"));
assert_eq!(d.current_status.as_deref(), Some("unavailable"));
}
#[test]
fn empty_data_object_is_all_none_not_rejected() {
let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &json!({})).unwrap()
else {
panic!("expected MessageDelivered");
};
assert!(d.latency_ms.is_none() && d.hop_count.is_none() && d.transport.is_none());
}
#[test]
fn out_of_range_smallint_is_rejected() {
let data = json!({ "hop_count": 40000 });
let err = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap_err();
assert!(
err.contains(TYPE_MESSAGE_DELIVERED),
"reason names the type: {err}"
);
}
#[test]
fn wrong_field_type_is_rejected() {
let data = json!({ "latency_ms": "fast" });
assert!(classify(TYPE_MESSAGE_DELIVERED, &data).is_err());
}
#[test]
fn non_object_data_for_known_type_is_rejected() {
assert!(classify(TYPE_MESSAGE_FAILED, &Value::Null).is_err());
assert!(classify(TYPE_MESSAGE_FAILED, &json!("nope")).is_err());
}
#[test]
fn unknown_type_falls_through_to_extension() {
let data = json!({ "anything": 1 });
let Event::Extension { ext_name, payload } =
classify("protocol.brand.new_event", &data).unwrap()
else {
panic!("expected Extension");
};
assert_eq!(ext_name, "protocol.brand.new_event");
assert_eq!(payload, data);
}
#[test]
fn failed_and_deferred_share_reason_retry_shape() {
let data = json!({ "reason_class": "retries_exhausted", "retry_count": 3 });
assert!(matches!(
classify(TYPE_MESSAGE_FAILED, &data).unwrap(),
Event::MessageFailed(_)
));
assert!(matches!(
classify(TYPE_MESSAGE_DEFERRED, &data).unwrap(),
Event::MessageDeferred(_)
));
}
#[test]
fn session_summary_partial_payload() {
let data = json!({ "session_duration_s": 312, "mls_session_ready_latency_p50_ms": 88 });
let Event::SessionSummary(d) = classify(TYPE_SESSION_SUMMARY, &data).unwrap() else {
panic!("expected SessionSummary");
};
assert_eq!(d.session_duration_s, Some(312));
assert_eq!(d.mls_session_ready_latency_p50_ms, Some(88));
assert!(d.messages_sent.is_none());
}
#[test]
fn negative_value_within_column_range_is_accepted() {
let data = json!({ "hop_count": -1, "latency_ms": -5 });
let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap() else {
panic!("expected MessageDelivered");
};
assert_eq!(d.hop_count, Some(-1));
assert_eq!(d.latency_ms, Some(-5));
}
#[test]
fn smallint_min_and_max_round_trip_through_classify() {
for v in [i16::MIN, i16::MAX] {
let data = json!({ "hop_count": v });
let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap()
else {
panic!("expected MessageDelivered for hop_count={v}");
};
assert_eq!(d.hop_count, Some(v));
}
assert!(classify(
TYPE_MESSAGE_DELIVERED,
&json!({ "hop_count": i16::MAX as i64 + 1 })
)
.is_err());
assert!(classify(
TYPE_MESSAGE_DELIVERED,
&json!({ "hop_count": i16::MIN as i64 - 1 })
)
.is_err());
}
}