Skip to main content

bloop_server_framework/
trigger.rs

1//! This module provides functionality to define, manage, and track triggers,
2//! which represent event-based activations identified by NFC tags (`NfcUid`).
3//!
4//! Triggers can be configured with different usage policies, such as single
5//! use, limited number of uses, or duration-based activation. The module
6//! supports both local (per client) and global triggers.
7//!
8//! # Key types
9//!
10//! - [`TriggerOccurrence`]: Specifies how often or for how long a trigger
11//!   remains active.
12//! - [`TriggerSpec`]: Defines the properties of a trigger including its type
13//!   and occurrence.
14//! - [`ActiveTrigger`]: Tracks usage and activation time for an active trigger
15//!   instance.
16//! - [`TriggerRegistry`]: Holds trigger specifications and active triggers,
17//!   providing methods to activate and check triggers per client.
18//!
19//! # Usage
20//!
21//! To use triggers, initialize a `TriggerRegistry` with your trigger
22//! specifications, then activate triggers upon NFC scans (`NfcUid`) and check
23//! their active status for clients.
24//!
25//! The registry automatically manages usage counts and expiration of active
26//! triggers.
27
28use crate::nfc_uid::NfcUid;
29use chrono::{DateTime, Utc};
30use serde::Deserialize;
31use std::collections::HashMap;
32use std::collections::hash_map::Entry;
33use std::time::Duration;
34
35/// Specifies how often or for how long a trigger should remain active.
36#[derive(Debug, Copy, Clone, Default, Deserialize)]
37pub enum TriggerOccurrence {
38    /// The trigger can only be used once.
39    #[default]
40    Once,
41    /// The trigger can be used a specified number of times.
42    Times(usize),
43    /// The trigger remains active for the specified duration.
44    Duration(Duration),
45}
46
47/// Defines the specification of a trigger, including its activation policy and type.
48#[derive(Debug, Copy, Clone, Deserialize)]
49pub struct TriggerSpec<T> {
50    /// Whether the trigger is global (affects all clients) or local (per client).
51    #[serde(default)]
52    pub global: bool,
53    /// How often or for how long this trigger can be active.
54    #[serde(default)]
55    pub occurrence: TriggerOccurrence,
56    /// The trigger identifier of type `T`.
57    pub trigger: T,
58}
59
60/// Represents an active trigger instance, tracking its usage and activation time.
61#[derive(Debug)]
62struct ActiveTrigger<T> {
63    /// The specification of the trigger being tracked.
64    spec: TriggerSpec<T>,
65    /// The time when the trigger was activated.
66    triggered_at: DateTime<Utc>,
67    /// The number of times this trigger has been used.
68    usages: usize,
69}
70
71impl<T> ActiveTrigger<T> {
72    /// Creates a new active trigger instance for a given spec and client ID.
73    ///
74    /// The trigger is considered activated at the current time.
75    fn new(spec: TriggerSpec<T>) -> Self {
76        Self {
77            spec,
78            triggered_at: Utc::now(),
79            usages: 0,
80        }
81    }
82}
83
84impl<T: PartialEq> ActiveTrigger<T> {
85    /// Checks whether the trigger is active with respect to the provided trigger value and
86    /// reference time.
87    fn check(&mut self, trigger: T, reference_time: DateTime<Utc>) -> (bool, bool) {
88        if trigger != self.spec.trigger {
89            // The trigger value doesn't match this active trigger's spec.
90            return (false, true);
91        }
92
93        match self.spec.occurrence {
94            TriggerOccurrence::Once => (true, false),
95            TriggerOccurrence::Times(times) => {
96                let active = self.usages < times;
97                self.usages += 1;
98                (active, self.usages < times)
99            }
100            TriggerOccurrence::Duration(duration) => {
101                let still_active = self.triggered_at + duration >= reference_time;
102                (still_active, still_active)
103            }
104        }
105    }
106}
107
108/// Registry that manages trigger specifications and tracks active triggers.
109#[derive(Debug)]
110pub struct TriggerRegistry<T> {
111    trigger_specs: HashMap<NfcUid, TriggerSpec<T>>,
112    active_local_triggers: HashMap<String, ActiveTrigger<T>>,
113    active_global_trigger: Option<ActiveTrigger<T>>,
114}
115
116impl<T> From<HashMap<NfcUid, TriggerSpec<T>>> for TriggerRegistry<T> {
117    fn from(specs: HashMap<NfcUid, TriggerSpec<T>>) -> Self {
118        Self::new(specs)
119    }
120}
121
122impl<T> TriggerRegistry<T> {
123    /// Creates a new [TriggerRegistry] from a set of trigger specifications.
124    pub fn new(specs: HashMap<NfcUid, TriggerSpec<T>>) -> Self {
125        Self {
126            trigger_specs: specs,
127            active_local_triggers: HashMap::new(),
128            active_global_trigger: None,
129        }
130    }
131}
132
133impl<T: PartialEq> TriggerRegistry<T> {
134    /// Checks if there is an active trigger for the given trigger and client at the specified time.
135    ///
136    /// This will update usage counts and remove triggers that should no longer be retained.
137    pub(crate) fn check_active_trigger(
138        &mut self,
139        trigger: T,
140        client_id: &str,
141        reference_time: DateTime<Utc>,
142    ) -> bool {
143        if let Entry::Occupied(mut active_trigger) =
144            self.active_local_triggers.entry(client_id.to_string())
145        {
146            let (active, retain) = active_trigger.get_mut().check(trigger, reference_time);
147
148            if !retain {
149                active_trigger.remove();
150            }
151
152            return active;
153        }
154
155        self.active_global_trigger
156            .take()
157            .is_some_and(|mut active_trigger| {
158                let (active, retain) = active_trigger.check(trigger, reference_time);
159
160                if retain {
161                    self.active_global_trigger = Some(active_trigger);
162                }
163
164                active
165            })
166    }
167}
168
169impl<T: Copy> TriggerRegistry<T> {
170    /// Attempts to activate a trigger based on the provided NFC UID and client ID.
171    ///
172    /// If the NFC UID is associated with a trigger spec, an active trigger is created and stored.
173    pub(crate) fn try_activate_trigger(&mut self, nfc_uid: NfcUid, client_id: &str) -> bool {
174        let Some(spec) = self.trigger_specs.get(&nfc_uid) else {
175            return false;
176        };
177
178        let active_trigger = ActiveTrigger::new(*spec);
179
180        if spec.global {
181            self.active_global_trigger = Some(active_trigger);
182        } else {
183            self.active_local_triggers
184                .insert(client_id.to_string(), active_trigger);
185        }
186
187        true
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use chrono::Utc;
195    use std::time::Duration;
196
197    #[test]
198    fn activate_and_check_once_trigger() {
199        let uid = NfcUid::default();
200        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
201            uid,
202            TriggerSpec {
203                global: false,
204                occurrence: TriggerOccurrence::Once,
205                trigger: 42,
206            },
207        )])
208        .into();
209
210        assert!(registry.try_activate_trigger(uid, "client1"));
211
212        let now = Utc::now();
213        assert!(registry.check_active_trigger(42, "client1", now));
214        assert!(!registry.check_active_trigger(42, "client1", now));
215    }
216
217    #[test]
218    fn activate_and_check_times_trigger() {
219        let uid = NfcUid::default();
220        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
221            uid,
222            TriggerSpec {
223                global: false,
224                occurrence: TriggerOccurrence::Times(2),
225                trigger: 42,
226            },
227        )])
228        .into();
229
230        assert!(registry.try_activate_trigger(uid, "client2"));
231
232        let now = Utc::now();
233        assert!(registry.check_active_trigger(42, "client2", now));
234        assert!(registry.check_active_trigger(42, "client2", now));
235        assert!(!registry.check_active_trigger(42, "client2", now));
236    }
237
238    #[test]
239    fn activate_and_check_duration_trigger() {
240        let uid = NfcUid::default();
241        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
242            uid,
243            TriggerSpec {
244                global: false,
245                occurrence: TriggerOccurrence::Duration(Duration::from_secs(50)),
246                trigger: 42,
247            },
248        )])
249        .into();
250
251        assert!(registry.try_activate_trigger(uid, "client3"));
252
253        let now = Utc::now();
254        assert!(registry.check_active_trigger(42, "client3", now));
255
256        let later = now + Duration::from_secs(30);
257        assert!(registry.check_active_trigger(42, "client3", later));
258
259        let expired = now + Duration::from_secs(70);
260        assert!(!registry.check_active_trigger(42, "client3", expired));
261    }
262
263    #[test]
264    fn global_trigger_works_from_any_client() {
265        let uid = NfcUid::default();
266        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
267            uid,
268            TriggerSpec {
269                global: true,
270                occurrence: TriggerOccurrence::Once,
271                trigger: 42,
272            },
273        )])
274        .into();
275
276        assert!(registry.try_activate_trigger(uid, "any_client"));
277
278        let now = Utc::now();
279        assert!(registry.check_active_trigger(42, "client1", now));
280        assert!(!registry.check_active_trigger(42, "client2", now));
281    }
282}