Skip to main content

visual_cortex/
watcher.rs

1use std::collections::HashSet;
2use std::sync::Arc;
3use std::time::SystemTime;
4
5use arc_swap::ArcSwapOption;
6use tokio::sync::broadcast;
7use tokio::time::MissedTickBehavior;
8
9use crate::error::{Error, Result};
10use crate::event::{Event, EventKind, ThresholdDirection};
11use crate::session::{recompute_capture_rate, Session, WatcherEntry};
12use crate::stream::EventStream;
13use visual_cortex_capture::{Frame, FrameView, Rate, Region};
14use visual_cortex_vision::{
15    Detector, DetectorError, DetectorOutput, FrameDiff, OcrDetector, OcrEngine, TemplateMatcher,
16};
17
18const DEGRADED_THRESHOLD: u32 = 5;
19
20pub(crate) enum Mode {
21    Change,
22    EveryMatch,
23    Threshold(Box<dyn Fn(f64) -> bool + Send + 'static>),
24    Template { threshold: f32 },
25}
26
27/// Configures one watcher. Created via `Session::watch`; consumed by `subscribe`.
28pub struct WatcherBuilder<'s> {
29    session: &'s Session,
30    name: String,
31    region: Region,
32    rate: Rate,
33    frame_diff: bool,
34    gates: Vec<Box<dyn Detector>>,
35    detector: Option<Box<dyn Detector>>,
36    mode: Mode,
37    template_error: Option<Error>,
38}
39
40impl<'s> WatcherBuilder<'s> {
41    pub(crate) fn new(session: &'s Session, name: &str) -> Self {
42        Self {
43            session,
44            name: name.to_string(),
45            region: Region::Full,
46            rate: Rate::hz(1.0),
47            frame_diff: true,
48            gates: Vec::new(),
49            detector: None,
50            mode: Mode::Change,
51            template_error: None,
52        }
53    }
54
55    pub fn region(mut self, region: Region) -> Self {
56        self.region = region;
57        self
58    }
59
60    pub fn rate(mut self, rate: Rate) -> Self {
61        self.rate = rate;
62        self
63    }
64
65    /// The implicit pixels-changed gate. On by default; disable for detectors
66    /// that must run even when the region is static.
67    pub fn frame_diff(mut self, enabled: bool) -> Self {
68        self.frame_diff = enabled;
69        self
70    }
71
72    /// Add a gate: a boolean detector that must report `Bool(true)` for the
73    /// main detector to run this tick. Gates run in registration order.
74    pub fn gate(mut self, gate: impl Detector) -> Self {
75        self.gates.push(Box::new(gate));
76        self
77    }
78
79    pub fn detector(mut self, detector: impl Detector) -> Self {
80        self.detector = Some(Box::new(detector));
81        self
82    }
83
84    /// Default mode: emit `Changed` when the detector output transitions.
85    pub fn on_change(mut self) -> Self {
86        self.mode = Mode::Change;
87        self
88    }
89
90    /// Emit `Matched` on every tick where the detector matches.
91    pub fn on_every_match(mut self) -> Self {
92        self.mode = Mode::EveryMatch;
93        self
94    }
95
96    /// Emit `ThresholdCrossed` when `predicate(numeric_output)` flips.
97    pub fn on_threshold(mut self, predicate: impl Fn(f64) -> bool + Send + 'static) -> Self {
98        self.mode = Mode::Threshold(Box::new(predicate));
99        self
100    }
101
102    /// Watch for a PNG template. Emits `TemplateAppeared` when the best match
103    /// score reaches `threshold` (0.0..=1.0) and `TemplateVanished` when it
104    /// falls back below. Sets both the detector and the emission mode; like
105    /// all builder configuration, the last call wins.
106    pub fn template(mut self, png_bytes: &[u8], threshold: f32) -> Self {
107        match TemplateMatcher::from_png_bytes(png_bytes) {
108            Ok(matcher) => {
109                self.detector = Some(Box::new(matcher));
110                self.mode = Mode::Template { threshold };
111            }
112            Err(e) => {
113                self.template_error =
114                    Some(Error::InvalidTemplate(self.name.clone(), e.to_string()));
115            }
116        }
117        self
118    }
119
120    /// OCR the region and emit text transitions (`Changed` with
121    /// `DetectorOutput::Text`/`None`). "Text appeared" is the
122    /// `None -> Text(..)` transition. Sets the detector only; the emission
123    /// mode stays chainable.
124    pub fn ocr_text(mut self, engine: impl OcrEngine) -> Self {
125        self.detector = Some(Box::new(OcrDetector::text(engine)));
126        self
127    }
128
129    /// OCR the region and parse a number out of the text (see
130    /// [`patterns::number`](visual_cortex_vision::patterns::number)). Pair with
131    /// `.on_threshold(..)` for HP-bar-style watchers.
132    pub fn ocr(
133        mut self,
134        engine: impl OcrEngine,
135        pattern: impl Fn(&str) -> Option<f64> + Send + 'static,
136    ) -> Self {
137        self.detector = Some(Box::new(OcrDetector::number(engine, pattern)));
138        self
139    }
140
141    /// Register the watcher and return its event stream.
142    pub fn subscribe(self) -> Result<EventStream> {
143        if let Some(e) = self.template_error {
144            return Err(e);
145        }
146
147        let detector = self
148            .detector
149            .ok_or_else(|| Error::MissingDetector(self.name.clone()))?;
150        // Atomically reserve the name so duplicate watchers fail fast, storing
151        // the rate now so a concurrent recompute already sees this watcher.
152        {
153            let mut watchers = self.session.inner.watchers.lock().unwrap();
154            if watchers.contains_key(&self.name) {
155                return Err(Error::DuplicateWatcher(self.name));
156            }
157            watchers.insert(
158                self.name.clone(),
159                WatcherEntry {
160                    rate: self.rate,
161                    task: None,
162                },
163            );
164        }
165
166        let rx = self.session.inner.events.subscribe();
167        let runtime = WatcherRuntime {
168            name: Arc::from(self.name.as_str()),
169            region: self.region,
170            rate: self.rate,
171            frame_diff: self.frame_diff,
172            gates: self.gates,
173            detector: Some(detector),
174            mode: self.mode,
175        };
176        let task = tokio::spawn(watcher_loop(
177            runtime,
178            self.session.inner.slot.clone(),
179            self.session.inner.events.clone(),
180        ));
181        self.session
182            .inner
183            .watchers
184            .lock()
185            .unwrap()
186            .get_mut(&self.name)
187            .expect("entry reserved above")
188            .task = Some(task);
189        recompute_capture_rate(&self.session.inner);
190        Ok(EventStream::new(rx, HashSet::from([self.name])))
191    }
192}
193
194struct WatcherRuntime {
195    name: Arc<str>,
196    region: Region,
197    rate: Rate,
198    frame_diff: bool,
199    gates: Vec<Box<dyn Detector>>,
200    detector: Option<Box<dyn Detector>>,
201    mode: Mode,
202}
203
204async fn watcher_loop(
205    mut w: WatcherRuntime,
206    slot: Arc<ArcSwapOption<Frame>>,
207    events: broadcast::Sender<Event>,
208) {
209    let mut interval = tokio::time::interval(w.rate.period());
210    interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
211    let mut diff_gate = w.frame_diff.then(FrameDiff::new);
212    let mut last_output: Option<DetectorOutput> = None;
213    let mut inside_threshold: Option<bool> = None;
214    let mut consecutive_errors: u32 = 0;
215
216    loop {
217        interval.tick().await;
218        let Some(frame) = slot.load_full() else {
219            continue;
220        };
221        let rect = match w.region.resolve(frame.width(), frame.height()) {
222            Ok(rect) => rect,
223            Err(e) => {
224                tracing::warn!(watcher = %w.name, "region does not fit frame: {e}");
225                continue;
226            }
227        };
228        let view = match frame.view(rect) {
229            Ok(view) => view,
230            Err(e) => {
231                tracing::warn!(watcher = %w.name, "crop failed: {e}");
232                continue;
233            }
234        };
235        if let Some(gate) = &mut diff_gate {
236            if matches!(gate.evaluate(&view), Ok(DetectorOutput::Bool(false))) {
237                continue;
238            }
239        }
240        if !gates_pass(&mut w.gates, &view, &w.name) {
241            continue;
242        }
243        let result = if w.detector.as_ref().is_some_and(|d| d.is_heavy()) {
244            // Move the detector and an owned frame handle onto the blocking
245            // pool; CPU-bound inference must not stall the async scheduler.
246            let mut detector = w.detector.take().expect("detector present");
247            let frame = frame.clone();
248            match tokio::task::spawn_blocking(move || {
249                // AssertUnwindSafe: a caught panic may leave the detector
250                // logically inconsistent; we accept that — subsequent
251                // failures count toward WatcherDegraded.
252                let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
253                    let view = frame.view(rect).expect("rect was validated for this frame");
254                    detector.evaluate(&view)
255                }))
256                .unwrap_or_else(|_| Err(DetectorError::Other("detector panicked".to_string())));
257                (detector, out)
258            })
259            .await
260            {
261                Ok((detector, out)) => {
262                    w.detector = Some(detector);
263                    out
264                }
265                Err(join_err) => {
266                    // Unreachable in practice: detector panics are caught above,
267                    // and blocking tasks are not cancelled once started. If it
268                    // does happen the detector is lost, so the watcher cannot
269                    // continue; signal subscribers and end the loop. (The
270                    // registry entry remains until unsubscribe/session drop.)
271                    tracing::error!(watcher = %w.name, "blocking task failed: {join_err}");
272                    send(
273                        &events,
274                        &w.name,
275                        EventKind::WatcherDegraded {
276                            consecutive_errors: consecutive_errors + 1,
277                        },
278                    );
279                    return;
280                }
281            }
282        } else {
283            w.detector
284                .as_mut()
285                .expect("detector present")
286                .evaluate(&view)
287        };
288        match result {
289            Ok(output) => {
290                consecutive_errors = 0;
291                emit(
292                    &w.mode,
293                    &w.name,
294                    &events,
295                    output,
296                    &mut last_output,
297                    &mut inside_threshold,
298                );
299            }
300            Err(e) => {
301                consecutive_errors += 1;
302                tracing::warn!(
303                    watcher = %w.name,
304                    "detector error ({consecutive_errors} consecutive): {e}"
305                );
306                if consecutive_errors == DEGRADED_THRESHOLD {
307                    send(
308                        &events,
309                        &w.name,
310                        EventKind::WatcherDegraded { consecutive_errors },
311                    );
312                }
313            }
314        }
315    }
316}
317
318fn gates_pass(gates: &mut [Box<dyn Detector>], view: &FrameView<'_>, name: &Arc<str>) -> bool {
319    for gate in gates.iter_mut() {
320        match gate.evaluate(view) {
321            Ok(DetectorOutput::Bool(true)) => {}
322            Ok(_) => return false, // Bool(false) or any non-boolean output closes the gate
323            Err(e) => {
324                tracing::warn!(watcher = %name, "gate error, skipping tick: {e}");
325                return false;
326            }
327        }
328    }
329    true
330}
331
332fn send(events: &broadcast::Sender<Event>, name: &Arc<str>, kind: EventKind) {
333    let _ = events.send(Event {
334        watcher: name.clone(),
335        timestamp: SystemTime::now(),
336        kind,
337    });
338}
339
340fn emit(
341    mode: &Mode,
342    name: &Arc<str>,
343    events: &broadcast::Sender<Event>,
344    output: DetectorOutput,
345    last_output: &mut Option<DetectorOutput>,
346    inside_threshold: &mut Option<bool>,
347) {
348    match mode {
349        Mode::EveryMatch => {
350            let matched = !matches!(output, DetectorOutput::None | DetectorOutput::Bool(false));
351            if matched {
352                send(events, name, EventKind::Matched { output });
353            }
354        }
355        Mode::Change => {
356            if last_output.as_ref() != Some(&output) {
357                send(
358                    events,
359                    name,
360                    EventKind::Changed {
361                        old: last_output.clone(),
362                        new: output.clone(),
363                    },
364                );
365            }
366            *last_output = Some(output);
367        }
368        Mode::Threshold(predicate) => {
369            let Some(value) = output.as_number() else {
370                return;
371            };
372            let inside = predicate(value);
373            match *inside_threshold {
374                // First observation: fire only if we start inside the threshold —
375                // "HP is already low" is worth knowing; "HP is fine" is not.
376                None if inside => send(
377                    events,
378                    name,
379                    EventKind::ThresholdCrossed {
380                        value,
381                        direction: ThresholdDirection::Entered,
382                    },
383                ),
384                Some(prev) if prev != inside => {
385                    let direction = if inside {
386                        ThresholdDirection::Entered
387                    } else {
388                        ThresholdDirection::Exited
389                    };
390                    send(
391                        events,
392                        name,
393                        EventKind::ThresholdCrossed { value, direction },
394                    );
395                }
396                _ => {}
397            }
398            *inside_threshold = Some(inside);
399        }
400        Mode::Template { threshold } => {
401            let Some(score) = output.as_number() else {
402                return;
403            };
404            let present = score >= *threshold as f64;
405            match *inside_threshold {
406                // First observation: only an already-visible template is news.
407                None if present => send(
408                    events,
409                    name,
410                    EventKind::TemplateAppeared {
411                        score: score as f32,
412                    },
413                ),
414                Some(prev) if prev != present => {
415                    if present {
416                        send(
417                            events,
418                            name,
419                            EventKind::TemplateAppeared {
420                                score: score as f32,
421                            },
422                        );
423                    } else {
424                        send(events, name, EventKind::TemplateVanished);
425                    }
426                }
427                _ => {}
428            }
429            *inside_threshold = Some(present);
430        }
431    }
432}