mod credentials;
mod registration;
use std::path::Path;
use anyhow::Result;
use arora_bridge::{Bridge, DeviceInfo};
use arora_hal::HalDescription;
use arora_studio_bridge_client::firestore_support::options::{
FirebaseEmulatorOptions, FirebaseOptions,
};
use arora_studio_bridge_client::zenoh::ZenohDeviceClient;
use log::{info, warn};
use crate::device_dir;
use crate::operator::Operator;
use arora_studio_bridge_client::credentials::DeviceCredentials;
use registration::Registration;
const OWNER_LABEL: &str =
"Semio Studio owner UID(s), comma-separated — leave empty to skip (disables Studio support)";
const NAME_LABEL: &str = "Device name — leave empty for a generated name";
const MODEL_FAMILY_LABEL: &str = "Model family — optional, leave empty to skip";
pub(crate) async fn default_bridge(
frontend: &crate::operator::Frontend,
description: &HalDescription,
) -> Result<Box<dyn Bridge>> {
let client = if frontend.interactive {
connect_with_operator_and_description(&*frontend.operator, description).await?
} else {
Some(connect_with_description(description).await?)
};
match client {
Some(client) => Ok(client),
None => {
info!("studio-bridge: skipped by operator (no owner) — running without Studio");
crate::run::local_ws_bridge().await
}
}
}
pub async fn connect_with_operator(operator: &dyn Operator) -> Result<Option<Box<dyn Bridge>>> {
connect_with_operator_and_description(operator, &HalDescription::default()).await
}
pub async fn connect_with_operator_and_description(
operator: &dyn Operator,
description: &HalDescription,
) -> Result<Option<Box<dyn Bridge>>> {
let saved_at = registration::path_in(&device_dir::from_env()?);
let saved = registration::load(&saved_at);
let Some(resolved) =
resolve_with_operator(operator, &env_nonempty, saved.as_ref(), description).await
else {
return Ok(None);
};
if resolved.from_saved {
log_saved_use(&resolved.info, &saved_at);
}
let credentials = credentials::from_env()?;
if let Some(answered) = &resolved.answered {
match registration::save(&saved_at, answered) {
Ok(()) => info!(
"studio: saved the registration in {}; the next run asks nothing (delete it to \
answer again)",
saved_at.display()
),
Err(e) => warn!(
"studio: could not save the registration in {}: {e}; the next run asks again",
saved_at.display()
),
}
}
Ok(Some(
build_studio_bridge(credentials, Some(resolved.info)).await?,
))
}
pub async fn connect() -> Result<Box<dyn Bridge>> {
connect_with_description(&HalDescription::default()).await
}
pub async fn connect_with_description(description: &HalDescription) -> Result<Box<dyn Bridge>> {
let info = unattended_info(&device_dir::from_env()?, description);
build_studio_bridge(credentials::from_env()?, info).await
}
pub async fn connect_with_device_dir(device_dir: &Path) -> Result<Box<dyn Bridge>> {
build_studio_bridge(
credentials::in_device_dir(device_dir)?,
unattended_info(device_dir, &HalDescription::default()),
)
.await
}
async fn build_studio_bridge(
credentials: DeviceCredentials,
info: Option<DeviceInfo>,
) -> Result<Box<dyn Bridge>> {
let firebase_options = FirebaseOptions::from_env();
let firebase_emulator_options = FirebaseEmulatorOptions::from_env();
let endpoint_override = std::env::var("STUDIO_BRIDGE_ENDPOINT")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
let client = match endpoint_override {
Some(endpoint) => {
info!("Connecting to Semio Studio via Zenoh (endpoint: {endpoint})");
ZenohDeviceClient::new_endpoint_with_credentials(
&firebase_options,
Some(&firebase_emulator_options),
&credentials,
info,
endpoint,
)
.await
}
None => {
info!("Connecting to Semio Studio via the baked-in bridge endpoint");
ZenohDeviceClient::new_with_credentials(
&firebase_options,
Some(&firebase_emulator_options),
&credentials,
info,
)
.await
}
}
.map_err(|e| anyhow::anyhow!("failed to connect to Semio Studio via Zenoh: {e:?}"))?;
let client: Box<dyn Bridge> = Box::new(client);
Ok(client)
}
#[derive(Debug, PartialEq)]
struct Resolved {
info: DeviceInfo,
from_saved: bool,
answered: Option<Registration>,
}
async fn resolve_with_operator(
operator: &dyn Operator,
env: &dyn Fn(&str) -> Option<String>,
saved: Option<&Registration>,
description: &HalDescription,
) -> Option<Resolved> {
let mut asked = false;
let mut from_saved = false;
let owners = match (env("DEVICE_OWNERS"), saved) {
(Some(owners), _) => parse_owners(&owners),
(None, Some(saved)) => {
from_saved = true;
saved.owners.clone()
}
(None, None) => {
asked = true;
operator
.ask_text(OWNER_LABEL, false)
.await
.map(|owners| parse_owners(&owners))
.unwrap_or_default()
}
};
if owners.is_empty() {
return None;
}
let name = match (env("DEVICE_NAME"), saved) {
(Some(name), _) => name,
(None, Some(saved)) => {
from_saved = true;
saved.name.clone()
}
(None, None) => {
asked = true;
ask_nonblank(operator, NAME_LABEL)
.await
.unwrap_or_else(generated_device_name)
}
};
let mut typed_model_family = None;
let model_family = match (env("MODEL_FAMILY"), hal_field(&description.model_family)) {
(Some(model_family), _) => Some(model_family),
(None, Some(model_family)) => {
info!("studio: model family {model_family}, as the HAL describes the device");
Some(model_family)
}
(None, None) => match saved {
Some(saved) => {
from_saved |= saved.model_family.is_some();
saved.model_family.clone()
}
None => {
asked = true;
typed_model_family = ask_nonblank(operator, MODEL_FAMILY_LABEL).await;
typed_model_family.clone()
}
},
};
let answered = asked.then(|| Registration {
owners: owners.clone(),
name: name.clone(),
model_family: typed_model_family,
});
Some(Resolved {
info: DeviceInfo {
name: Some(name),
description: env("DEVICE_DESCRIPTION"),
model_family,
hardware_version: env("HARDWARE_VERSION")
.or_else(|| hal_field(&description.hardware_version)),
software_version: env("SOFTWARE_VERSION")
.or_else(|| hal_field(&description.software_version)),
owners,
},
from_saved,
answered,
})
}
fn resolve_unattended(
env: &dyn Fn(&str) -> Option<String>,
saved: Option<&Registration>,
description: &HalDescription,
) -> Option<Resolved> {
let from_env = DeviceInfo {
name: env("DEVICE_NAME"),
description: env("DEVICE_DESCRIPTION"),
model_family: env("MODEL_FAMILY"),
hardware_version: env("HARDWARE_VERSION"),
software_version: env("SOFTWARE_VERSION"),
owners: env("DEVICE_OWNERS")
.map(|owners| parse_owners(&owners))
.unwrap_or_default(),
};
if from_env == DeviceInfo::default() && saved.is_none() {
return None;
}
let mut from_saved = false;
let owners = match (from_env.owners.is_empty(), saved) {
(true, Some(saved)) => {
from_saved = true;
saved.owners.clone()
}
_ => from_env.owners,
};
let name = or_saved(
from_env.name,
saved.map(|saved| saved.name.clone()),
&mut from_saved,
);
let model_family = or_saved(
from_env
.model_family
.or_else(|| hal_field(&description.model_family)),
saved.and_then(|saved| saved.model_family.clone()),
&mut from_saved,
);
Some(Resolved {
info: DeviceInfo {
name,
description: from_env.description,
model_family,
hardware_version: from_env
.hardware_version
.or_else(|| hal_field(&description.hardware_version)),
software_version: from_env
.software_version
.or_else(|| hal_field(&description.software_version)),
owners,
},
from_saved,
answered: None,
})
}
fn or_saved(value: Option<String>, saved: Option<String>, from_saved: &mut bool) -> Option<String> {
value.or_else(|| {
*from_saved |= saved.is_some();
saved
})
}
fn unattended_info(device_dir: &Path, description: &HalDescription) -> Option<DeviceInfo> {
let saved_at = registration::path_in(device_dir);
let saved = registration::load(&saved_at);
let resolved = resolve_unattended(&env_nonempty, saved.as_ref(), description)?;
if resolved.from_saved {
log_saved_use(&resolved.info, &saved_at);
}
Some(resolved.info)
}
fn log_saved_use(info: &DeviceInfo, saved_at: &Path) {
info!(
"studio: registering as {} for owner(s) {} (saved in {}; delete it to answer again)",
info.name.as_deref().unwrap_or("(no name)"),
info.owners.join(", "),
saved_at.display()
);
}
fn parse_owners(owners: &str) -> Vec<String> {
owners
.split(',')
.map(|owner| owner.trim().to_string())
.filter(|owner| !owner.is_empty())
.collect()
}
async fn ask_nonblank(operator: &dyn Operator, label: &str) -> Option<String> {
operator
.ask_text(label, false)
.await
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn hal_field(value: &Option<String>) -> Option<String> {
value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn env_nonempty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn generated_device_name() -> String {
format!("arora-device-{}", random_device_suffix())
}
fn random_device_suffix() -> String {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(std::process::id() as u64);
let mut value = hasher.finish();
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut suffix = String::with_capacity(8);
for _ in 0..8 {
suffix.push(ALPHABET[(value % 36) as usize] as char);
value /= 36;
}
suffix
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
use arora_bridge::AccessDecision;
use super::*;
use crate::operator::{AccessRequestSummary, AccessRuling};
#[derive(Default)]
struct Scripted {
answers: HashMap<&'static str, &'static str>,
asked: Mutex<Vec<String>>,
}
impl Scripted {
fn answering(answers: &[(&'static str, &'static str)]) -> Self {
Scripted {
answers: answers.iter().copied().collect(),
..Scripted::default()
}
}
fn asked(&self) -> Vec<String> {
self.asked.lock().unwrap().clone()
}
}
#[async_trait]
impl Operator for Scripted {
async fn ask_text(&self, label: &str, _required: bool) -> Option<String> {
self.asked.lock().unwrap().push(label.to_string());
self.answers.get(label).map(|answer| answer.to_string())
}
async fn decide_access(&self, _request: &AccessRequestSummary) -> AccessRuling {
AccessRuling {
decision: AccessDecision::Rejected,
remember: false,
}
}
}
fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let vars: HashMap<String, String> = vars
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect();
move |key| vars.get(key).cloned()
}
fn description(model_family: Option<&str>, version: Option<&str>) -> HalDescription {
HalDescription {
model_family: model_family.map(str::to_string),
hardware_version: version.map(str::to_string),
software_version: version.map(str::to_string),
}
}
fn saved(model_family: Option<&str>) -> Registration {
Registration {
owners: vec!["saved-uid".to_string()],
name: "saved-name".to_string(),
model_family: model_family.map(str::to_string),
}
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
#[tokio::test]
async fn a_device_registered_once_is_asked_nothing_again() {
let device =
std::env::temp_dir().join(format!("arora-studio-asked-once-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&device);
let saved_at = registration::path_in(&device);
let no_hal = HalDescription::default();
let operator = Scripted::answering(&[
(OWNER_LABEL, " uid-1, uid-2 "),
(NAME_LABEL, "pepper-lab"),
(MODEL_FAMILY_LABEL, "pepper"),
]);
let first = resolve_with_operator(&operator, &env(&[]), None, &no_hal)
.await
.expect("an owner was given");
assert_eq!(
operator.asked(),
[OWNER_LABEL, NAME_LABEL, MODEL_FAMILY_LABEL]
);
assert!(!first.from_saved);
let answered = first.answered.expect("the answers are to be saved");
registration::save(&saved_at, &answered).unwrap();
let operator = Scripted::default();
let saved = registration::load(&saved_at);
let second = resolve_with_operator(&operator, &env(&[]), saved.as_ref(), &no_hal)
.await
.expect("the saved owner registers the device");
assert!(operator.asked().is_empty(), "asked {:?}", operator.asked());
assert_eq!(second.info, first.info);
assert!(second.from_saved);
assert_eq!(second.answered, None, "nothing new to save");
std::fs::remove_dir_all(&device).unwrap();
}
#[tokio::test]
async fn a_blank_model_family_answer_is_remembered_as_none() {
let operator = Scripted::answering(&[(OWNER_LABEL, "uid"), (NAME_LABEL, "")]);
let first = resolve_with_operator(&operator, &env(&[]), None, &HalDescription::default())
.await
.unwrap();
let answered = first.answered.unwrap();
assert!(
answered.name.starts_with("arora-device-"),
"a blank name is generated, and the generated one saved: {}",
answered.name
);
assert_eq!(answered.model_family, None);
let operator = Scripted::default();
let second = resolve_with_operator(
&operator,
&env(&[]),
Some(&answered),
&HalDescription::default(),
)
.await
.unwrap();
assert!(operator.asked().is_empty(), "asked {:?}", operator.asked());
assert_eq!(second.info.name, Some(answered.name));
assert_eq!(second.info.model_family, None);
}
#[tokio::test]
async fn an_empty_owner_declines_studio_and_saves_nothing() {
let operator = Scripted::answering(&[(OWNER_LABEL, " , ")]);
let resolved =
resolve_with_operator(&operator, &env(&[]), None, &HalDescription::default()).await;
assert_eq!(resolved, None);
assert_eq!(operator.asked(), [OWNER_LABEL], "nothing else is asked");
}
#[tokio::test]
async fn the_environment_wins_over_the_saved_registration() {
let operator = Scripted::default();
let resolved = resolve_with_operator(
&operator,
&env(&[
("DEVICE_OWNERS", "env-uid"),
("DEVICE_NAME", "env-name"),
("MODEL_FAMILY", "env-model"),
]),
Some(&saved(Some("saved-model"))),
&description(Some("hal-model"), None),
)
.await
.unwrap();
assert!(operator.asked().is_empty());
assert_eq!(resolved.info.owners, strings(&["env-uid"]));
assert_eq!(resolved.info.name.as_deref(), Some("env-name"));
assert_eq!(resolved.info.model_family.as_deref(), Some("env-model"));
assert!(!resolved.from_saved);
assert_eq!(resolved.answered, None);
}
#[tokio::test]
async fn the_hal_gives_the_model_family_and_versions_unless_the_environment_does() {
let operator = Scripted::answering(&[(OWNER_LABEL, "uid"), (NAME_LABEL, "nao")]);
let resolved = resolve_with_operator(
&operator,
&env(&[("SOFTWARE_VERSION", "env-sw")]),
None,
&description(Some("nao"), Some("hal-v")),
)
.await
.unwrap();
assert_eq!(operator.asked(), [OWNER_LABEL, NAME_LABEL]);
assert_eq!(resolved.info.model_family.as_deref(), Some("nao"));
assert_eq!(resolved.info.hardware_version.as_deref(), Some("hal-v"));
assert_eq!(resolved.info.software_version.as_deref(), Some("env-sw"));
assert_eq!(resolved.answered.unwrap().model_family, None);
let operator = Scripted::default();
let resolved = resolve_with_operator(
&operator,
&env(&[]),
Some(&saved(Some("saved-model"))),
&description(Some("pepper"), None),
)
.await
.unwrap();
assert!(operator.asked().is_empty());
assert_eq!(resolved.info.model_family.as_deref(), Some("pepper"));
assert_eq!(resolved.info.owners, strings(&["saved-uid"]));
}
#[tokio::test]
async fn the_operator_is_asked_only_for_what_nothing_gives() {
let operator = Scripted::answering(&[(NAME_LABEL, "typed-name")]);
let resolved = resolve_with_operator(
&operator,
&env(&[("DEVICE_OWNERS", "env-uid"), ("MODEL_FAMILY", "env-model")]),
None,
&HalDescription::default(),
)
.await
.unwrap();
assert_eq!(operator.asked(), [NAME_LABEL]);
assert_eq!(
resolved.answered,
Some(Registration {
owners: strings(&["env-uid"]),
name: "typed-name".to_string(),
model_family: None,
}),
"the resolved owners and name are saved, an env model family is not"
);
}
#[test]
fn unattended_nothing_set_nothing_saved_registers_nothing() {
assert_eq!(
resolve_unattended(&env(&[]), None, &description(Some("nao"), Some("v"))),
None,
"the HAL's description alone registers nothing"
);
}
#[test]
fn unattended_a_saved_registration_registers_the_device() {
let resolved = resolve_unattended(
&env(&[]),
Some(&saved(Some("saved-model"))),
&description(None, Some("hal-v")),
)
.unwrap();
assert!(resolved.from_saved);
assert_eq!(
resolved.info,
DeviceInfo {
name: Some("saved-name".to_string()),
description: None,
model_family: Some("saved-model".to_string()),
hardware_version: Some("hal-v".to_string()),
software_version: Some("hal-v".to_string()),
owners: strings(&["saved-uid"]),
}
);
}
#[test]
fn unattended_the_environment_then_the_hal_then_the_saved_registration() {
let resolved = resolve_unattended(
&env(&[("DEVICE_NAME", "env-name"), ("HARDWARE_VERSION", "env-hw")]),
Some(&saved(Some("saved-model"))),
&description(Some("hal-model"), Some("hal-v")),
)
.unwrap();
assert_eq!(
resolved.info,
DeviceInfo {
name: Some("env-name".to_string()),
description: None,
model_family: Some("hal-model".to_string()),
hardware_version: Some("env-hw".to_string()),
software_version: Some("hal-v".to_string()),
owners: strings(&["saved-uid"]),
}
);
}
#[test]
fn unattended_without_a_saved_registration_is_the_environment_s() {
let resolved = resolve_unattended(
&env(&[("DEVICE_OWNERS", "a, b"), ("DEVICE_NAME", "n")]),
None,
&HalDescription::default(),
)
.unwrap();
assert!(!resolved.from_saved);
assert_eq!(
resolved.info,
DeviceInfo {
name: Some("n".to_string()),
owners: strings(&["a", "b"]),
..DeviceInfo::default()
}
);
}
}