Skip to main content

flighthook/
message.rs

1//! Unified `FlighthookMessage` bus types.
2//!
3//! All events flow through a single `broadcast<FlighthookMessage>` channel.
4//! Each message has a source (global ID of the originator), timestamp,
5//! optional raw payload (hex-first policy), and a typed event.
6//! Producers create messages; consumers subscribe and filter.
7
8use std::fmt;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize, Serializer};
12
13use crate::ShotDetectionMode;
14use crate::{ActorState, MevoConfigEvent, ShotData};
15use crate::{ClubInfo, GameStateSnapshot, PlayerInfo};
16use crate::{
17    FlighthookConfig, GsProSection, MevoSection, MockMonitorSection, RandomClubSection,
18    WebserverSection,
19};
20
21// ---------------------------------------------------------------------------
22// Top-level message
23// ---------------------------------------------------------------------------
24
25/// A single event on the unified bus.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct FlighthookMessage {
28    #[serde(default)]
29    pub source: String,
30    pub timestamp: DateTime<Utc>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub raw_payload: Option<RawPayload>,
33    pub event: FlighthookEvent,
34}
35
36#[cfg(not(target_arch = "wasm32"))]
37impl FlighthookMessage {
38    /// Create a new message with the current UTC timestamp. Use `.source()` and
39    /// `.raw()` / `.raw_binary()` to attach metadata.
40    pub fn new(event: impl Into<FlighthookEvent>) -> Self {
41        Self {
42            source: String::new(),
43            timestamp: Utc::now(),
44            raw_payload: None,
45            event: event.into(),
46        }
47    }
48
49    pub fn source(mut self, source: impl Into<String>) -> Self {
50        self.source = source.into();
51        self
52    }
53
54    pub fn raw(mut self, raw: RawPayload) -> Self {
55        self.raw_payload = Some(raw);
56        self
57    }
58
59    pub fn raw_binary(mut self, raw: Vec<u8>) -> Self {
60        self.raw_payload = Some(RawPayload::Binary(raw));
61        self
62    }
63}
64
65// ---------------------------------------------------------------------------
66// From impls — inner event types -> FlighthookEvent
67// ---------------------------------------------------------------------------
68
69impl From<LaunchMonitorEvent> for FlighthookEvent {
70    fn from(event: LaunchMonitorEvent) -> Self {
71        FlighthookEvent::LaunchMonitor(LaunchMonitorRecv { event })
72    }
73}
74
75impl From<GameStateCommandEvent> for FlighthookEvent {
76    fn from(event: GameStateCommandEvent) -> Self {
77        FlighthookEvent::GameStateCommand(GameStateCommand { event })
78    }
79}
80
81impl From<ConfigChanged> for FlighthookEvent {
82    fn from(changed: ConfigChanged) -> Self {
83        FlighthookEvent::ConfigChanged(changed)
84    }
85}
86
87impl From<ActorState> for FlighthookEvent {
88    fn from(state: ActorState) -> Self {
89        FlighthookEvent::ActorStatus(state)
90    }
91}
92
93impl From<ConfigCommand> for FlighthookEvent {
94    fn from(cmd: ConfigCommand) -> Self {
95        FlighthookEvent::ConfigCommand(cmd)
96    }
97}
98
99impl From<ConfigOutcome> for FlighthookEvent {
100    fn from(result: ConfigOutcome) -> Self {
101        FlighthookEvent::ConfigOutcome(result)
102    }
103}
104
105impl From<AlertMessage> for FlighthookEvent {
106    fn from(alert: AlertMessage) -> Self {
107        FlighthookEvent::Alert(alert)
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Raw payload — hex-first policy
113// ---------------------------------------------------------------------------
114
115/// Raw wire data attached to a bus message.
116///
117/// Binary payloads serialize as hex strings (no spaces, lowercase).
118/// Text payloads (e.g. GSPro JSON) serialize as-is.
119#[derive(Debug, Clone)]
120pub enum RawPayload {
121    Binary(Vec<u8>),
122    Text(String),
123}
124
125impl Serialize for RawPayload {
126    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
127        match self {
128            RawPayload::Binary(bytes) => {
129                let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
130                serializer.serialize_str(&hex)
131            }
132            RawPayload::Text(s) => serializer.serialize_str(s),
133        }
134    }
135}
136
137impl<'de> Deserialize<'de> for RawPayload {
138    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
139        let s = String::deserialize(deserializer)?;
140        Ok(RawPayload::Text(s))
141    }
142}
143
144impl fmt::Display for RawPayload {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            RawPayload::Binary(bytes) => {
148                for b in bytes {
149                    write!(f, "{b:02x}")?;
150                }
151                Ok(())
152            }
153            RawPayload::Text(s) => write!(f, "{s}"),
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Event variants
160// ---------------------------------------------------------------------------
161
162/// The typed event payload carried by a `FlighthookMessage`.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(tag = "kind", rename_all = "snake_case")]
165pub enum FlighthookEvent {
166    /// Data from a launch monitor (shots).
167    LaunchMonitor(LaunchMonitorRecv),
168    /// Configuration changed (emitted by reconfigure, consumed by integrations).
169    ConfigChanged(ConfigChanged),
170    /// Global state command (from an integration or WS client).
171    GameStateCommand(GameStateCommand),
172    /// Global state snapshot (emitted after GameStateCommand is applied).
173    GameStateSnapshot(GameStateSnapshot),
174    /// Third-party user data (from WS clients via integrations).
175    UserData(UserDataMessage),
176    /// Generic actor status update (replaces per-type status events).
177    ActorStatus(ActorState),
178    /// Config mutation request (emitted by POST handler).
179    ConfigCommand(ConfigCommand),
180    /// Config mutation outcome (emitted by SystemActor after processing).
181    ConfigOutcome(ConfigOutcome),
182    /// Alert for user-visible warn/error conditions.
183    Alert(AlertMessage),
184}
185
186impl FlighthookEvent {
187    /// Returns true if this is an ActorStatus event containing telemetry
188    /// (battery_pct key in state map). Used for heartbeat filtering in audit.
189    pub fn is_actor_status_with_telemetry(&self) -> bool {
190        matches!(self, FlighthookEvent::ActorStatus(state) if state.telemetry.contains_key("battery_pct"))
191    }
192}
193
194// ---------------------------------------------------------------------------
195// LaunchMonitor — shot data from a launch monitor
196// ---------------------------------------------------------------------------
197
198/// Envelope for events from a launch monitor.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct LaunchMonitorRecv {
201    pub event: LaunchMonitorEvent,
202}
203
204/// Individual events from a launch monitor.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(tag = "type", rename_all = "snake_case")]
207pub enum LaunchMonitorEvent {
208    /// Completed shot.
209    ShotResult { shot: Box<ShotData> },
210    /// Armed/ready state change.
211    ReadyState { armed: bool, ball_detected: bool },
212}
213
214// ---------------------------------------------------------------------------
215// ConfigChanged — generic configuration update notification
216// ---------------------------------------------------------------------------
217
218/// Configuration changed notification. Emitted by `reconfigure()` when an
219/// actor's settings change; consumed by integrations (e.g. GSPro bridge).
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct ConfigChanged {
222    pub config: MevoConfigEvent,
223}
224
225// ---------------------------------------------------------------------------
226// GameStateCommand — from integrations / WS clients
227// ---------------------------------------------------------------------------
228
229/// A command to update global state, originating from an integration or WS client.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GameStateCommand {
232    pub event: GameStateCommandEvent,
233}
234
235/// The specific global state mutation.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(tag = "type", rename_all = "snake_case")]
238pub enum GameStateCommandEvent {
239    SetPlayerInfo { player_info: PlayerInfo },
240    SetClubInfo { club_info: ClubInfo },
241    SetMode { mode: ShotDetectionMode },
242}
243
244// ---------------------------------------------------------------------------
245// UserData — third-party WS integrations
246// ---------------------------------------------------------------------------
247
248/// Opaque data from a third-party WS client.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct UserDataMessage {
251    #[serde(default)]
252    pub integration_type: String,
253    #[serde(default)]
254    pub source_id: String,
255    #[serde(default)]
256    pub data: serde_json::Value,
257}
258
259// ---------------------------------------------------------------------------
260// ConfigCommand — config mutation request
261// ---------------------------------------------------------------------------
262
263/// A request to mutate the system configuration.
264///
265/// Emitted on the bus by the POST handler. Processed exclusively by
266/// `SystemActor`, which applies the mutation, reconciles actors, and
267/// optionally emits a `ConfigOutcome`.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ConfigCommand {
270    /// Opaque correlation ID. When present, SystemActor emits a ConfigOutcome
271    /// with the same ID after processing (request-reply pattern).
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub request_id: Option<String>,
274    pub action: ConfigAction,
275}
276
277/// The specific config mutation to apply.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(tag = "type", rename_all = "snake_case")]
280pub enum ConfigAction {
281    /// Replace the entire config. Used by POST /api/settings.
282    ReplaceAll {
283        config: FlighthookConfig,
284    },
285    /// Per-section upserts.
286    UpsertWebserver {
287        index: String,
288        section: WebserverSection,
289    },
290    UpsertMevo {
291        index: String,
292        section: MevoSection,
293    },
294    UpsertGsPro {
295        index: String,
296        section: GsProSection,
297    },
298    UpsertMockMonitor {
299        index: String,
300        section: MockMonitorSection,
301    },
302    UpsertRandomClub {
303        index: String,
304        section: RandomClubSection,
305    },
306    /// Remove a section by global ID ("mevo.0", "gspro.1", "webserver.0").
307    Remove {
308        id: String,
309    },
310}
311
312// ---------------------------------------------------------------------------
313// ConfigOutcome — config mutation acknowledgment
314// ---------------------------------------------------------------------------
315
316/// Acknowledgment of a config mutation, emitted by SystemActor after
317/// processing a `ConfigCommand` that had a `request_id`.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct ConfigOutcome {
320    pub request_id: String,
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub restarted: Vec<String>,
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub stopped: Vec<String>,
325    #[serde(default, skip_serializing_if = "Vec::is_empty")]
326    pub started: Vec<String>,
327}
328
329// ---------------------------------------------------------------------------
330// AlertMessage — user-visible warn/error notifications
331// ---------------------------------------------------------------------------
332
333/// Severity level for alert messages.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336pub enum AlertLevel {
337    Warn,
338    Error,
339}
340
341impl fmt::Display for AlertLevel {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        match self {
344            AlertLevel::Warn => write!(f, "warn"),
345            AlertLevel::Error => write!(f, "error"),
346        }
347    }
348}
349
350/// A user-visible alert. Info/debug/trace stays in the tracing backend;
351/// warn/error conditions surface here for the UI log panel.
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct AlertMessage {
354    pub level: AlertLevel,
355    pub message: String,
356}