cargagep-v2/src/services/linka/locks.rs
Antoine Pelletier 2d6ef8de6c feat: add linka
2026-08-25 12:10:53 +02:00

115 lines
3.6 KiB
Rust

//! 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<i32>,
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<Vec<Lock>> {
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<Lock> {
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);
}
}