1use std::path::{Path, PathBuf};
45use std::sync::atomic::{AtomicU8, Ordering};
46use std::sync::mpsc::{self, Receiver, Sender};
47use std::sync::{Arc, Mutex, OnceLock};
48use std::time::{Duration, Instant};
49
50use base64::Engine as _;
51use sensorviz_session::{OrientationSample, Preferences, Session, Side};
52use serde_json::{Map, Value, json};
53
54#[derive(Debug, Clone, Default)]
56pub struct EngineConfig {
57 pub home_dir: PathBuf,
59 pub user_guide_url: Option<String>,
61 pub initial_log: Option<PathBuf>,
63 pub initial_preferences: Option<PathBuf>,
65 pub preview_dialog: Option<String>,
68}
69
70#[derive(Debug, Clone)]
76pub enum Event {
77 State(Value),
79 Log {
81 level: &'static str,
83 text: String,
85 },
86}
87
88#[derive(Clone)]
93pub struct Engine {
94 tx: Sender<Msg>,
96}
97
98#[derive(Debug)]
100enum Msg {
101 Command(String, Value),
103 Query(String, Value, Sender<Value>),
105}
106
107impl Engine {
108 pub fn command(&self, cmd: &str, args: Value) {
111 let _ = self.tx.send(Msg::Command(cmd.to_owned(), args));
112 }
113
114 pub fn query(&self, name: &str, args: Value) -> Value {
119 let (reply_tx, reply_rx) = mpsc::channel();
120 if self
121 .tx
122 .send(Msg::Query(name.to_owned(), args, reply_tx))
123 .is_err()
124 {
125 return Value::Null;
126 }
127 reply_rx
128 .recv_timeout(Duration::from_secs(30))
129 .unwrap_or(Value::Null)
130 }
131}
132
133type Sink = Arc<dyn Fn(Event) + Send + Sync>;
135
136static SINK: OnceLock<Mutex<Option<Sink>>> = OnceLock::new();
138static LOG_LEVEL: AtomicU8 = AtomicU8::new(3);
140
141pub struct UiLogger;
144
145pub static LOGGER: UiLogger = UiLogger;
147
148impl log::Log for UiLogger {
149 fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
151 metadata.level() as u8 <= LOG_LEVEL.load(Ordering::Relaxed)
152 }
153
154 fn log(&self, record: &log::Record<'_>) {
156 if !self.enabled(record.metadata()) {
157 return;
158 }
159 let level = match record.level() {
160 log::Level::Error => "ERROR",
161 log::Level::Warn => "WARN",
162 log::Level::Info => "INFO",
163 log::Level::Debug => "DEBUG",
164 log::Level::Trace => "TRACE",
165 };
166 if let Some(sink) = SINK.get().and_then(|s| s.lock().ok()?.clone()) {
167 sink(Event::Log {
168 level,
169 text: record.args().to_string(),
170 });
171 }
172 }
173
174 fn flush(&self) {}
176}
177
178pub fn spawn(config: EngineConfig, sink: impl Fn(Event) + Send + Sync + 'static) -> Engine {
187 let sink: Sink = Arc::new(sink);
188 *SINK.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(sink.clone());
189 log::set_max_level(log::LevelFilter::Info);
190 let viewer = Viewer::new(config).expect("sensorviz session");
191 let (tx, rx) = mpsc::channel();
192 std::thread::Builder::new()
193 .name("sensorviz-engine".into())
194 .spawn(move || run(viewer, sink, rx))
195 .expect("engine thread");
196 Engine { tx }
197}
198
199fn run(mut viewer: Viewer, sink: Sink, rx: Receiver<Msg>) {
201 let mut last_state = Value::Null;
202 while let Ok(msg) = rx.recv() {
203 match msg {
204 Msg::Command(cmd, args) => viewer.command(&cmd, &args),
205 Msg::Query(name, args, reply) => {
206 let _ = reply.send(viewer.query(&name, &args));
207 }
208 }
209 let state = viewer.view_state();
210 if state != last_state {
211 sink(Event::State(state.clone()));
212 last_state = state;
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq)]
219struct Message {
220 seq: u64,
222 title: String,
224 text: String,
226}
227
228pub struct Viewer {
230 config: EngineConfig,
232 session: Session,
234 preferences: Preferences,
236 message: Option<Message>,
238 last_dir: PathBuf,
240 compass: Vec<OrientationSample>,
242}
243
244fn arg_str<'a>(args: &'a Value, key: &str) -> &'a str {
246 args.get(key).and_then(Value::as_str).unwrap_or("")
247}
248
249fn arg_f64(args: &Value, key: &str) -> Option<f64> {
251 args.get(key).and_then(Value::as_f64)
252}
253
254fn arg_bool(args: &Value, key: &str) -> bool {
256 args.get(key).and_then(Value::as_bool).unwrap_or(false)
257}
258
259fn parse_color(text: &str) -> Option<u32> {
261 let hex = text.strip_prefix('#')?;
262 if hex.len() != 6 {
263 return None;
264 }
265 u32::from_str_radix(hex, 16).ok()
266}
267
268impl Viewer {
269 pub fn new(config: EngineConfig) -> Result<Self, sensorviz_session::Error> {
276 Ok(Viewer {
277 last_dir: config.home_dir.clone(),
278 session: Session::new()?,
279 preferences: Preferences::new()?,
280 message: None,
281 compass: Vec::new(),
282 config,
283 })
284 }
285
286 fn tell(&mut self, title: &str, text: impl Into<String>) {
288 let seq = self.message.as_ref().map_or(1, |m| m.seq + 1);
289 self.message = Some(Message {
290 seq,
291 title: title.into(),
292 text: text.into(),
293 });
294 }
295
296 fn remember_dir(&mut self, path: &Path) {
298 if let Some(dir) = path.parent() {
299 self.last_dir = dir.to_path_buf();
300 }
301 }
302
303 fn has_log(&mut self) -> bool {
305 self.session
306 .catalog()
307 .map(|c| !c.path.is_empty())
308 .unwrap_or(false)
309 }
310
311 pub fn command(&mut self, cmd: &str, args: &Value) {
313 match cmd {
314 "ready" => {
315 if let Some(path) = self.config.initial_log.clone() {
316 self.open(&path);
317 }
318 if let Some(path) = self.config.initial_preferences.clone() {
319 self.load_preferences(&path);
320 }
321 }
322 "open" => self.open(Path::new(arg_str(args, "path"))),
323 "set_visible_streams" => {
324 let ids: Vec<&str> = args
325 .get("ids")
326 .and_then(Value::as_array)
327 .map(|a| a.iter().filter_map(Value::as_str).collect())
328 .unwrap_or_default();
329 let _ = self.session.set_visible_streams(&ids);
330 }
331 "set_stream_visible" => {
332 self.session
333 .set_stream_visible(arg_str(args, "id"), arg_bool(args, "visible"));
334 }
335 "set_stream_range" => {
336 let (Some(lower), Some(upper)) = (arg_f64(args, "lower"), arg_f64(args, "upper"))
337 else {
338 return;
339 };
340 if lower >= upper {
341 self.tell("Set Range", "Minimum must be less than maximum.");
342 return;
343 }
344 self.session
345 .set_stream_range(arg_str(args, "id"), lower, upper);
346 }
347 "reset_zoom" => {
348 self.session.reset_zoom();
349 }
350 "set_break_at_gaps" => {
351 self.session.set_break_at_gaps(arg_bool(args, "on"));
352 }
353 "set_cursors_visible" => {
354 self.session.set_cursors_visible(arg_bool(args, "on"));
355 }
356 "set_utc_offset" => {
357 if let Some(hours) = arg_f64(args, "hours") {
358 self.session
359 .set_utc_offset(hours.round().clamp(-12.0, 12.0) as i32);
360 }
361 }
362 "set_parameter" => {
363 if let Some(value) = arg_f64(args, "value")
364 && let Err(e) = self.session.set_parameter(arg_str(args, "id"), value)
365 {
366 log::warn!("{}: {e}", arg_str(args, "id"));
367 }
368 }
369 "set_title" => {
370 let _ = self.session.set_title(arg_str(args, "text"));
371 }
372 "set_title_visible" => {
373 self.session.set_title_visible(arg_bool(args, "on"));
374 }
375 "set_colors" => {
376 let colors: Vec<(String, u32)> = args
377 .get("colors")
378 .and_then(Value::as_object)
379 .map(|m| {
380 m.iter()
381 .filter_map(|(id, c)| Some((id.clone(), parse_color(c.as_str()?)?)))
382 .collect()
383 })
384 .unwrap_or_default();
385 let refs: Vec<(&str, u32)> =
386 colors.iter().map(|(id, c)| (id.as_str(), *c)).collect();
387 let _ = self.session.set_colors(&refs);
388 }
389 "set_axis_sides" => {
390 let sides: Vec<(String, Side)> = args
391 .get("sides")
392 .and_then(Value::as_object)
393 .map(|m| {
394 m.iter()
395 .map(|(id, s)| {
396 let side = if s.as_str() == Some("right") {
397 Side::Right
398 } else {
399 Side::Left
400 };
401 (id.clone(), side)
402 })
403 .collect()
404 })
405 .unwrap_or_default();
406 let refs: Vec<(&str, Side)> =
407 sides.iter().map(|(id, s)| (id.as_str(), *s)).collect();
408 let _ = self.session.set_axis_sides(&refs);
409 }
410 "load_preferences" => self.load_preferences(Path::new(arg_str(args, "path"))),
411 "store_preferences" => self.store_preferences(Path::new(arg_str(args, "path"))),
412 "load_default_preferences" => {
413 if self.has_log() {
414 self.session.forget_preferences(&mut self.preferences);
415 self.session.apply_preferences(&self.preferences);
416 }
417 }
418 "save_file" => {
419 let path = PathBuf::from(arg_str(args, "path"));
420 let written = base64::engine::general_purpose::STANDARD
421 .decode(arg_str(args, "base64"))
422 .map_err(|e| e.to_string())
423 .and_then(|bytes| std::fs::write(&path, bytes).map_err(|e| e.to_string()));
424 match written {
425 Ok(()) => {
426 self.remember_dir(&path);
427 log::info!("Saved {}", path.display());
428 }
429 Err(e) => self.tell(
430 "Save Failed",
431 format!("Could not write {}: {e}", path.display()),
432 ),
433 }
434 }
435 other => log::warn!("unknown command {other}"),
436 }
437 }
438
439 fn open(&mut self, path: &Path) {
442 if self.has_log() {
443 self.session.remember_preferences(&mut self.preferences);
444 }
445 let started = Instant::now();
446 match self.session.open(path, Some(&self.preferences)) {
447 Ok(()) => {
448 self.remember_dir(path);
449 self.compass = self.session.compass_samples();
450 log::info!(
451 "Loaded {} in {} ms",
452 path.display(),
453 started.elapsed().as_millis()
454 );
455 if let Ok(catalog) = self.session.catalog() {
456 for failure in catalog.transform_failures {
457 if failure.transform == "compass" {
458 self.tell("Compass Streams", failure.error);
459 } else {
460 log::warn!("{}: {}", failure.transform, failure.error);
461 }
462 }
463 }
464 }
465 Err(e) => self.tell("Load Failed", e.to_string()),
466 }
467 }
468
469 fn load_preferences(&mut self, path: &Path) {
472 let loaded = std::fs::read_to_string(path)
473 .map_err(|e| format!("Could not open {}: {e}", path.display()))
474 .and_then(|text| {
475 self.preferences
476 .load(&text, &path.to_string_lossy())
477 .map_err(|e| e.to_string())
478 });
479 match loaded {
480 Ok(()) => {
481 self.remember_dir(path);
482 if self.has_log() {
483 self.session.apply_preferences(&self.preferences);
484 }
485 log::info!("Loaded sensorViz preferences from {}", path.display());
486 }
487 Err(e) => self.tell("Load Preferences Failed", e),
488 }
489 }
490
491 fn store_preferences(&mut self, path: &Path) {
494 if self.has_log() {
495 self.session.remember_preferences(&mut self.preferences);
496 }
497 let text = self.preferences.save();
498 match std::fs::write(path, text) {
499 Ok(()) => {
500 self.remember_dir(path);
501 log::info!("Saved sensorViz preferences to {}", path.display());
502 }
503 Err(e) => self.tell(
504 "Save Preferences Failed",
505 format!("Could not write {}: {e}", path.display()),
506 ),
507 }
508 }
509
510 pub fn query(&mut self, name: &str, args: &Value) -> Value {
512 match name {
513 "envelope" => self.envelope(args),
514 "hover" => {
515 let t = arg_f64(args, "t").unwrap_or(0.0);
516 let rows = self.session.hover(t).unwrap_or_default();
517 let mut rows = rows.into_iter();
518 let time = rows.next().unwrap_or_default();
519 json!({ "time": time, "rows": rows.map(|text| json!({ "text": text })).collect::<Vec<_>>() })
520 }
521 "range" => match self.session.range_for_dialog(arg_str(args, "id")) {
522 Some((lower, upper)) => json!({ "lower": lower, "upper": upper }),
523 None => Value::Null,
524 },
525 "compass" => self.compass_at(arg_f64(args, "t")),
526 "about" => json!({
527 "title": "Tag View",
528 "version": env!("CARGO_PKG_VERSION"),
529 "userGuide": self.config.user_guide_url,
530 "previewDialog": self.config.preview_dialog,
531 }),
532 other => json!({ "error": format!("unknown query {other}") }),
533 }
534 }
535
536 fn compass_at(&mut self, t: Option<f64>) -> Value {
542 if self.compass.is_empty() {
543 return Value::Null;
544 }
545 let i = t
546 .map_or(0, |t| self.compass.partition_point(|s| s.time < t))
547 .min(self.compass.len() - 1);
548 let sample = self.compass[i];
549 let angles = calibsession::display_angles(sample.q);
550 let parameters = self
551 .session
552 .view()
553 .map(|v| v.parameters)
554 .unwrap_or_default();
555 let declination = parameters
556 .get("compass.declination_deg")
557 .copied()
558 .unwrap_or(0.0);
559 let forward = parameters
560 .get("compass.battery_forward")
561 .copied()
562 .unwrap_or(1.0)
563 != 0.0;
564 let (mut heading, mut pitch, mut roll) =
565 (angles.yaw + declination, angles.pitch, angles.roll);
566 if !forward {
567 heading += 180.0;
568 pitch = -pitch;
569 roll = -roll;
570 }
571 heading = (heading + 360.0) % 360.0;
572 json!({
573 "time": sample.time,
574 "heading": heading, "pitch": pitch, "roll": roll,
575 "dip": sample.dip, "field": sample.field, "gravity": sample.mg,
576 "declination": declination, "batteryForward": forward,
577 })
578 }
579
580 fn envelope(&mut self, args: &Value) -> Value {
582 let started = Instant::now();
583 let Ok(spec) = self.session.spec() else {
584 return json!({ "traces": {}, "ms": 0 });
585 };
586 let t0 = arg_f64(args, "t0").unwrap_or(f64::NEG_INFINITY);
587 let t1 = arg_f64(args, "t1").unwrap_or(f64::INFINITY);
588 let width = arg_f64(args, "width").unwrap_or(1.0).round().max(1.0) as i32;
589 let mut traces = Map::new();
590 for trace in &spec.traces {
591 let envelope = self
592 .session
593 .envelope(trace.stream, t0, t1, width, spec.break_at_gaps);
594 traces.insert(
595 trace.id.clone(),
596 json!({ "t": envelope.time, "v": envelope.value }),
597 );
598 }
599 json!({ "traces": traces, "ms": started.elapsed().as_secs_f64() * 1000.0 })
600 }
601
602 pub fn view_state(&mut self) -> Value {
604 let [catalog, view, spec] = self.session.state_json();
605 let catalog: Value = serde_json::from_str(&catalog).unwrap_or(Value::Null);
606 let view: Value = serde_json::from_str(&view).unwrap_or(Value::Null);
607 let has_log = catalog["path"].as_str().is_some_and(|p| !p.is_empty());
608 let parameters = if has_log {
609 self.session
610 .parameters()
611 .map(|ps| {
612 ps.into_iter()
613 .map(|p| {
614 json!({
615 "id": p.id, "title": p.title, "prompt": p.prompt, "units": p.units,
616 "kind": p.kind, "minimum": p.minimum, "maximum": p.maximum,
617 "decimals": p.decimals, "value": p.value,
618 })
619 })
620 .collect()
621 })
622 .unwrap_or_default()
623 } else {
624 Vec::new()
625 };
626 let path = catalog["path"].as_str().unwrap_or("").to_owned();
627 json!({
628 "title": "Tag View",
629 "hasLog": has_log,
630 "path": path,
631 "fileName": Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()),
632 "dialogDir": self.last_dir,
633 "status": status_line(&catalog, &view, has_log),
634 "fileInfo": if has_log { file_info(&catalog) } else { String::new() },
635 "timeDomain": catalog["timeDomain"],
636 "hasCompass": catalog["compassSource"].as_str().is_some_and(|s| !s.is_empty()),
637 "hasCalibration": catalog["compassCalibration"]["present"],
638 "calibration": catalog["compassCalibration"],
639 "spec": if has_log { serde_json::from_str(&spec).unwrap_or(Value::Null) } else { Value::Null },
640 "view": if has_log { view } else { Value::Null },
641 "parameters": parameters,
642 "message": self.message.as_ref().map(|m| json!({ "seq": m.seq, "title": m.title, "text": m.text })),
643 })
644 }
645}
646
647fn status_line(catalog: &Value, view: &Value, has_log: bool) -> String {
649 if !has_log {
650 return "No log loaded".into();
651 }
652 let streams = view["streams"].as_array().map_or(0, Vec::len);
653 let sets = catalog["recordSets"].as_array().map_or(0, Vec::len);
654 if sets == 0 {
655 format!("{streams} streams loaded")
656 } else {
657 format!("{streams} streams, {sets} record sets loaded")
658 }
659}
660
661fn file_info(catalog: &Value) -> String {
666 let text = |v: &Value| v.as_str().unwrap_or("").to_owned();
667 let mut lines = Vec::new();
668 let path = text(&catalog["path"]);
669 lines.push(
670 Path::new(&path)
671 .file_name()
672 .map_or(path.clone(), |n| n.to_string_lossy().into_owned()),
673 );
674 let tag_type = text(&catalog["tagType"]);
675 if !tag_type.is_empty() {
676 lines.push(format!("Tag type: {tag_type}"));
677 }
678 let profile = text(&catalog["profileName"]);
679 if !profile.is_empty() {
680 lines.push(format!("Profile: {profile}"));
681 }
682 if let Some(sets) = catalog["recordSets"].as_array().filter(|s| !s.is_empty()) {
683 let labels: Vec<String> = sets.iter().map(|s| text(&s["label"])).collect();
684 lines.push(format!("Record sets: {}", labels.join(", ")));
685 }
686 let calibration = &catalog["compassCalibration"];
687 let warning = text(&calibration["warning"]);
688 if calibration["present"].as_bool() == Some(true) {
689 lines.push("Compass calibration: loaded".into());
690 lines.push(format!(
691 "Compass calibration epoch: {}",
692 calibration["epoch"]
693 ));
694 if !warning.is_empty() {
695 lines.push(format!("Compass calibration warning: {warning}"));
696 }
697 } else if !warning.is_empty() {
698 lines.push(format!("Compass calibration: {warning}"));
699 }
700 let source = text(&catalog["compassSource"]);
701 if !source.is_empty() {
702 lines.push(format!("Compass orientation: {source}"));
703 }
704 if let Some(info) = catalog["info"].as_object() {
705 for (field, value) in info {
706 if field == "config" || field == "info" {
707 continue;
708 }
709 lines.push(format!("{field}: {}", value.as_str().unwrap_or("")));
710 }
711 }
712 lines.join("\n")
713}