dooris/ssh-cli/src/main.rs
lilly 5850cff536
Some checks failed
Build Container / Build Container (push) Failing after 2m43s
display lock warnings on every operation not just show-locks
2026-07-23 14:19:42 +02:00

190 lines
6.9 KiB
Rust

//! This is a small CLI intended to be run on a host that accepts dooris commands via SSH
//!
//! The SSH server should be configured to force execution of this command via the following server config snippet
//!
//! ```sshd_config
//! Match User dooris
//! AuthorizedKeysFile /path/to/api/state/authorized_keys
//! ForceCommand /path/to/ssh-cli
//! SetEnv DOORIS_SERVER_URL=https://dooris.ccchh.net/ DOORIS_API_TOKEN=foobar123
//! ```
mod api_client;
mod api_models;
mod cli;
use std::{process::exit, time::Duration};
use eyre::eyre;
use crate::{
api_client::ApiClient,
api_models::{ApiLock, DesiredLockState, LockState},
};
#[tokio::main(flavor = "current_thread")]
async fn main() -> eyre::Result<()> {
tracing_subscriber::fmt::init();
simple_eyre::install()?;
let args = cli::CliArgs::parse()?;
tracing::debug!(cli = ?args, "Parsed CLI parameters");
match args {
cli::CliArgs::Help => {
println!(
"dooris-over-ssh: Your personal door assistant with an extra serving of chaos"
);
println!(
"Usage: ssh dooris@dooris.ccchh.net <help | show-locks | lock <door> [wait|nowait] | unlock <door> [wait|nowait]>"
);
println!();
println!("Commands:");
println!();
println!(" help -> The help command prints this message. Hello 👋");
println!("");
println!(
" show-locks -> Display a list of currently connected locks along with their status."
);
println!("");
println!(" lock <lock> [wait|nowait] -> Locks the door identified with <lock>.");
println!(
" Also takes an additional parameter of \"wait\" or \"nowait\"which dictates"
);
println!(" whether the CLI exit as soon as the lock operation is queued or whether");
println!(" it waits until the lock status changes to closed.");
println!(" Defaults to \"wait\" if not specified.");
println!();
println!(" unlock <lock> [wait|nowait] -> Unlocks the lock identified with <lock>.");
println!(
" Also takes an additional parameter of \"wait\" or \"nowait\"which dictates"
);
println!(" whether the CLI exit as soon as the lock operation is queued or whether");
println!(" it waits until the lock status changes to open.");
println!(" Defaults to \"wait\" if not specified.");
exit(0);
}
cli::CliArgs::Lock {
lock_name,
wait_until_done,
} => {
let api = ApiClient::new_from_env()?;
let lock = find_lock(&api, &lock_name).await?;
println!("dooris at your service, locking {} now…", lock.name);
print_lock_warnings(&lock);
api.operate_lock(&lock.id, DesiredLockState::CLOSED).await?;
if wait_until_done {
wait_for_lock_state(&api, &lock_name, LockState::LOCKED).await?;
println!("Lock is now closed. Enjoy <3");
} else {
println!("Lock is now closing. Enjoy <3");
}
exit(0);
}
cli::CliArgs::Unlock {
lock_name,
wait_until_done,
} => {
let api = ApiClient::new_from_env()?;
let lock = find_lock(&api, &lock_name).await?;
println!("dooris at your service, unlocking {} now…", lock.name);
print_lock_warnings(&lock);
api.operate_lock(&lock.id, DesiredLockState::OPEN).await?;
if wait_until_done {
wait_for_lock_state(&api, &lock_name, LockState::UNLOCKED).await?;
println!("Lock is now opened. Enjoy <3");
} else {
println!("Lock is now opening. Enjoy <3");
}
exit(0);
}
cli::CliArgs::ShowLocks => {
let api = ApiClient::new_from_env()?;
let locks = api.fetch_locks().await?;
for i_lock in locks.iter() {
println!(">> {}", i_lock.name);
print_lock_warnings(&lock);
println!(" State: {:?}", i_lock.status.lock_state);
println!(" Desired State: {:?}", i_lock.status.lock_target_level);
println!(" Acitivty: {:?}", i_lock.status.activity_state);
println!();
}
exit(0);
}
};
}
async fn find_lock(api: &ApiClient, name: &str) -> eyre::Result<ApiLock> {
let locks = api.fetch_locks().await?;
tracing::trace!("Comparing locks from API for one with name {name:?}");
let lock = locks
.into_iter()
.find(|i_lock| i_lock.name.eq_ignore_ascii_case(&name))
.ok_or_else(|| {
eyre!("No lock named {name:?} exists. See \"list-locks\" for a list of valid locks.")
})?;
tracing::trace!(lock = ?lock, "Identified lock with name matching {name:?}");
Ok(lock)
}
async fn wait_for_lock_state(
api: &ApiClient,
lock_name: &str,
desired_state: LockState,
) -> eyre::Result<()> {
tokio::time::sleep(Duration::from_millis(200)).await;
let lock = find_lock(&api, &lock_name).await?;
let mut interval = tokio::time::interval(Duration::from_millis(500));
let mut last_status = lock.status;
while last_status.lock_state != desired_state {
interval.tick().await;
let lock = find_lock(&api, &lock_name).await?;
if lock.status != last_status {
println!(
"Lock state changed to {:?} (activity={:?})",
lock.status.lock_state, lock.status.activity_state
);
last_status = lock.status;
}
}
Ok(())
}
/// Display big warnings if the lock is in an error state like jammed/disconnected/low battery
fn print_lock_warnings(lock: &ApiLock) {
let has_any_error =
lock.status.is_unreachable || lock.status.is_low_battery || lock.status.is_error_jammed;
if has_any_error {
println!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
}
if lock.status.is_unreachable {
println!(" !!! !!!");
println!(" !!! Lock is currently unreachable !!!");
println!(" !!! !!!");
}
if lock.status.is_low_battery {
println!(" !!! !!!");
println!(" !!! Lock has low battery !!!");
println!(" !!! !!!");
}
if lock.status.is_error_jammed {
println!(" !!! !!!");
println!(" !!! Lock is jammed !!!");
println!(" !!! !!!");
}
if has_any_error {
println!(" !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
}
}