//! The locks: what they report, and putting one in or out of service. use openidconnect::reqwest::Method; use serde_json::{Value, json}; use tracing::info; use crate::{ core::models::linka::LockState, services::linka::{LinkaError, Result, dry_run, fleet, linka}, }; /// One lock, reduced to what this app shows or acts on #[derive(Debug, Clone, PartialEq)] pub struct Lock { /// The number written on the bike — the name it goes by here too pub number: String, pub state: LockState, pub battery: Option, pub out_of_service: bool, } /// Every lock of the account. /// /// The bounding box is the whole planet: the platform filters by map area, and /// this app wants the fleet, wherever it happens to be parked. pub async fn fetch() -> Result> { let answer = fleet( "merchantlocks", Method::PUT, json!({ "longitudeX": -180, "latitudeX": -90, "longitudeY": 180, "latitudeY": 90 }), ) .await?; let locks = answer .get("data") .and_then(Value::as_array) .ok_or_else(|| LinkaError::failed("merchantlocks answered without a list"))?; Ok(locks.iter().filter_map(read).collect()) } /// A lock with no number is of no use here: it could not be matched to a bike. fn read(lock: &Value) -> Option { let number = match lock.get("lock_number") { Some(Value::String(number)) => number.trim().to_owned(), Some(Value::Number(number)) => number.to_string(), _ => return None, }; Some(Lock { number, state: lock .get("lock_state") .and_then(Value::as_str) .map_or(LockState::Unknown, LockState::parse), battery: lock .get("lock_battery_percent") .and_then(Value::as_i64) .map(|percent| percent as i32), out_of_service: lock .get("out_of_service") .and_then(Value::as_bool) .unwrap_or(false), }) } /// Takes the lock of `serial` in or out of service on the platform. pub async fn set_service_state(serial: &str, out_of_service: bool) -> Result<()> { let linka = linka().ok_or(LinkaError::NotConfigured)?; if dry_run() { info!("[LINKA] (dry run) would set {serial} out_of_service={out_of_service}"); return Err(LinkaError::DryRun); } super::authenticated( &linka.service_state_url.clone(), Method::POST, json!({ "lock_serial_no": serial, "out_of_service": out_of_service }), ) .await .map(|_| ()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_lock_is_read_whether_its_number_is_written_as_text_or_as_a_number() { let text = read(&json!({ "lock_number": "3000", "lock_state": "Unlocked", "lock_battery_percent": 95, "out_of_service": false })) .unwrap(); assert_eq!(text.number, "3000"); assert_eq!(text.state, LockState::Unlocked); assert_eq!(text.battery, Some(95)); let number = read(&json!({ "lock_number": 1000, "lock_state": "Locked" })).unwrap(); assert_eq!(number.number, "1000"); assert_eq!(number.state, LockState::Locked); assert_eq!(number.battery, None); assert!(!number.out_of_service); } #[test] fn a_lock_without_a_number_is_dropped_rather_than_guessed_at() { assert!(read(&json!({ "lock_state": "Locked" })).is_none()); } #[test] fn an_unexpected_lock_state_is_reported_as_unknown() { let lock = read(&json!({ "lock_number": 1, "lock_state": "Ajar" })).unwrap(); assert_eq!(lock.state, LockState::Unknown); } }