From 0c2a7af2c9690dcf027cb9475841f1d7d34b7a44 Mon Sep 17 00:00:00 2001 From: Tanner Sommers Date: Mon, 4 Mar 2024 14:40:30 -0500 Subject: [PATCH] more progress --- bruno/BambuAPI/Get Devices.bru | 15 ++ bruno/BambuAPI/bruno.json | 9 + src-tauri/Cargo.lock | 89 +++++++++ src-tauri/Cargo.toml | 3 + src-tauri/src/commands/bambu/mod.rs | 71 ++++++- src-tauri/src/constants.rs | 1 + src-tauri/src/handlers/bambu/mod.rs | 270 ++++++++++++++++++++++++++- src-tauri/src/handlers/config/mod.rs | 3 + src-tauri/src/main.rs | 8 +- src/lib/types.ts | 23 +++ src/routes/setup/+page.svelte | 85 +++++++++ 11 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 bruno/BambuAPI/Get Devices.bru create mode 100644 bruno/BambuAPI/bruno.json diff --git a/bruno/BambuAPI/Get Devices.bru b/bruno/BambuAPI/Get Devices.bru new file mode 100644 index 0000000..40e7bd4 --- /dev/null +++ b/bruno/BambuAPI/Get Devices.bru @@ -0,0 +1,15 @@ +meta { + name: Get Devices + type: http + seq: 1 +} + +get { + url: https://api.bambulab.com/v1/iot-service/api/user/bind + body: none + auth: bearer +} + +auth:bearer { + token: +} diff --git a/bruno/BambuAPI/bruno.json b/bruno/BambuAPI/bruno.json new file mode 100644 index 0000000..fa1fa56 --- /dev/null +++ b/bruno/BambuAPI/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "BambuAPI", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c1dc909..17f94dd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -67,12 +67,15 @@ name = "app" version = "0.1.0" dependencies = [ "dirs", + "jsonwebtoken", + "lazy_static", "paho-mqtt", "reqwest", "serde", "serde_json", "tauri", "tauri-build", + "tokio", ] [[package]] @@ -1159,8 +1162,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1685,6 +1690,21 @@ dependencies = [ "treediff", ] +[[package]] +name = "jsonwebtoken" +version = "9.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7ea04a7c5c055c175f189b6dc6ba036fd62306b58c66c9f6389036c503a3f4" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kuchikiki" version = "0.8.2" @@ -1969,12 +1989,32 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.18" @@ -2209,6 +2249,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "pem" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" +dependencies = [ + "base64 0.21.7", + "serde", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2704,6 +2754,21 @@ dependencies = [ "windows 0.37.0", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.12", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2993,6 +3058,18 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -3052,6 +3129,12 @@ dependencies = [ "system-deps 5.0.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3789,6 +3872,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ee966cc..7975840 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,9 @@ tauri = { version = "1.6.0", features = [ "dialog-confirm", "dialog-ask", "dialo dirs = "5.0.1" reqwest = "0.11.24" paho-mqtt = "0.12.3" +lazy_static = "1.4.0" +tokio = "1.36.0" +jsonwebtoken = "9.2.0" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. diff --git a/src-tauri/src/commands/bambu/mod.rs b/src-tauri/src/commands/bambu/mod.rs index bdecd4f..589a674 100644 --- a/src-tauri/src/commands/bambu/mod.rs +++ b/src-tauri/src/commands/bambu/mod.rs @@ -1,10 +1,17 @@ -use crate::handlers::bambu::BambuClient; +use std::borrow::Borrow; + +use crate::handlers::bambu::{BambuClient, BambuDevice}; +use lazy_static::lazy_static; + +lazy_static! { + static ref BAMBU_CLIENT: BambuClient = BambuClient::new(); +} #[tauri::command] pub async fn login_to_bambu(username: String, password: String) -> Result { println!("[commands::bambu::login_to_bambu] trying to login to bambu with username: {} and password: {}", username, password); - let client = BambuClient::new(); + let client = BAMBU_CLIENT.borrow(); let response = client.login(&username, &password).await; println!("[commands::bambu::login_to_bambu] response: {:?}", response); @@ -18,3 +25,63 @@ pub async fn login_to_bambu(username: String, password: String) -> Result Err(e.to_string()), } } + +#[tauri::command] +pub async fn set_jwt(jwt: String) -> Result { + println!("[commands::bambu::set_jwt] setting jwt: {}", jwt); + + let client = BAMBU_CLIENT.borrow(); + client.set_jwt(jwt).await; + + Ok("".to_string()) +} + +#[tauri::command] +pub async fn get_jwt() -> Result { + println!("[commands::bambu::get_jwt] getting jwt"); + + let client = BAMBU_CLIENT.borrow(); + let jwt = client.get_jwt().await; + + println!("[commands::bambu::get_jwt] jwt: {:?}", jwt); + match jwt { + Some(jwt) => Ok(jwt), + None => Err("No jwt found".to_string()), + } +} + +#[tauri::command] +pub async fn fetch_devices() -> Result { + println!("[commands::bambu::fetch_devices] fetching devices"); + + let client = BAMBU_CLIENT.borrow(); + let devices = client.get_devices().await; + println!("[commands::bambu::fetch_devices] devices: {:?}", devices); + + match devices { + Ok(devices) => { + // Serialize the response to JSON + let serialized_devices = serde_json::to_string(&devices).map_err(|e| e.to_string())?; + Ok(serialized_devices) + } + Err(e) => Err(e.to_string()), + } +} + +#[tauri::command] +pub async fn discover_devices(devices: Vec) -> Result { + println!("[commands::bambu::discover_devices] discovering devices"); + + let client = BAMBU_CLIENT.borrow(); + let devices = client.get_device_ips(devices).await; + println!("[commands::bambu::discover_devices] devices: {:?}", devices); + + match devices { + Ok(devices) => { + // Serialize the response to JSON + let serialized_devices = serde_json::to_string(&devices).map_err(|e| e.to_string())?; + Ok(serialized_devices) + } + Err(e) => Err(e.to_string()), + } +} diff --git a/src-tauri/src/constants.rs b/src-tauri/src/constants.rs index a690926..a895297 100644 --- a/src-tauri/src/constants.rs +++ b/src-tauri/src/constants.rs @@ -1,4 +1,5 @@ pub static BAMBU_API_URL: &str = "https://api.bambulab.com"; +pub static BAMBU_AUDIENCE: &str = "account"; pub static BAMBU_LOGIN_URL: &str = "https://bambulab.com/api/sign-in/form"; pub static BAMBU_MQTT_URL: &str = "mqtts://us.mqtt.bambulab.com:8883"; pub static BAMBU_MQTT_INIT_PAYLOAD: &str = diff --git a/src-tauri/src/handlers/bambu/mod.rs b/src-tauri/src/handlers/bambu/mod.rs index 0d79f72..2dc5fd1 100644 --- a/src-tauri/src/handlers/bambu/mod.rs +++ b/src-tauri/src/handlers/bambu/mod.rs @@ -1,12 +1,17 @@ -use std::error; +use std::{borrow::Borrow, collections::HashSet, sync::Arc}; // Imports use crate::constants; -use serde::ser; -use serde_json::json; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use paho_mqtt::connect_options; +use reqwest::header::ValueIter; +use serde::de; +use serde_json::{json, Number}; +use tokio::sync::Mutex; pub struct BambuClient { client: reqwest::Client, + jwt: Mutex>, } #[derive(Debug, serde::Deserialize, serde::Serialize)] @@ -15,6 +20,60 @@ pub struct BambuUserResponse { refresh_token: String, } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct BambuUserJwt { + exp: i64, + iat: i64, + iss: String, + aud: String, + sub: String, + typ: String, + azp: String, + session_state: String, + realm_access: BambuUserRealmAccess, + resource_access: serde_json::Value, // todo: define this type + sid: String, + email_verified: bool, + preferred_username: String, + username: String, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct BambuUserRealmAccess { + roles: Vec, +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct BambuDeviceResponse { + message: String, + code: Option, + error: Option, + devices: Vec, +} + +// Define the BambuDeviceResponse's format +impl std::fmt::Display for BambuDeviceResponse { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "BambuDeviceResponse {{ message: {}, code: {:?}, error: {:?}, devices: {:?} }}", + self.message, self.code, self.error, self.devices + ) + } +} + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct BambuDevice { + dev_id: String, + name: String, + online: bool, + print_status: String, + dev_model_name: String, + dev_product_name: String, + dev_access_code: String, + nozzle_diameter: Number, +} + #[derive(Debug)] pub enum BambuLoginError { ReqwestError(reqwest::Error), @@ -31,12 +90,22 @@ impl std::fmt::Display for BambuLoginError { } impl BambuClient { - pub fn new() -> Self { - Self { + pub fn new() -> BambuClient { + BambuClient { client: reqwest::Client::new(), + jwt: Mutex::new(None), } } + // Create getters and setters for the jwt + pub async fn get_jwt(&self) -> Option { + self.jwt.lock().await.clone() + } + + pub async fn set_jwt(&self, jwt: String) { + *self.jwt.lock().await = Some(jwt); + } + pub async fn login( &self, username: &str, @@ -104,4 +173,195 @@ impl BambuClient { Err(e) => Err(BambuLoginError::ReqwestError(e)), } } + + pub async fn get_devices(&self) -> Result { + // Ensure we have a token to use + let token = + match self.get_jwt().await { + Some(token) => token, + None => return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Expected a token to be set before calling get_devices, but none was found.", + )), + }; + + // Send a GET request with authorization header + let response = self + .client + .get(format!( + "{}/v1/iot-service/api/user/bind", + constants::BAMBU_API_URL + )) + .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token)) + .send() + .await; + + match response { + Ok(response) => { + // Check if the response is successful + if !response.status().is_success() { + // Return an error if the response is not successful + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "Failed to get devices from Bambu with status code {}: \n\n{}", + response.status(), + response.text().await.unwrap_or("".to_string()) + ), + )); + } + + let response_text = response.text().await.unwrap_or("".to_string()); + + println!( + "[BambuClient::get_devices] response_text: {}", + response_text + ); + + // Parse the response body into a BambuDeviceResponse + let device_response: BambuDeviceResponse = serde_json::from_str(&response_text) + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "Failed to parse Bambu device response: {}\n\n{}", + e, response_text + ), + ) + })?; + + // Return the parsed response + Ok(device_response) + } + Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e)), + } + } + + pub async fn get_device_ips( + &self, + devices: Vec, + ) -> Result, std::io::Error> { + // Ensure we have a token to use + let token = + match self.get_jwt().await { + Some(token) => token, + None => return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Expected a token to be set before calling get_device_ips, but none was found.", + )), + }; + + // Because we don't have the private key, we need to "skip" the signature validation + // Though this is not recommended, it is the only way to decode the token without the private key. + let key = DecodingKey::from_secret(&[]); + let mut validation = Validation::new(Algorithm::HS256); + validation.insecure_disable_signature_validation(); + validation.set_audience(&[constants::BAMBU_AUDIENCE]); + + let jwt_decoded = + jsonwebtoken::decode::(&token, &key, &validation).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to decode Bambu JWT: {}", e), + ) + })?; + + // Create a new MQTT client + let mqtt_client = paho_mqtt::AsyncClient::new(constants::BAMBU_MQTT_URL).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to create MQTT client: {}", e), + ) + })?; + + let connect_options = { + let mut builder = paho_mqtt::ConnectOptionsBuilder::new(); + builder + .keep_alive_interval(std::time::Duration::from_secs(30)) + .user_name(jwt_decoded.claims.username) + .password(token) + .ssl_options(paho_mqtt::SslOptions::new()); + builder.finalize() + }; + + println!( + "[BambuClient::get_device_ips] Connecting to MQTT broker at {} with options: {:?}", + constants::BAMBU_MQTT_URL, + connect_options + ); + + // Connect to the MQTT broker + mqtt_client.connect(connect_options).await.map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to connect to MQTT broker: {}", e), + ) + })?; + + mqtt_client.set_message_callback(move |_, msg| { + if let Some(msg) = msg { + println!( + "[BambuClient::get_device_ips] Received message on topic {}: {}", + msg.topic(), + msg.payload_str() + ); + } + }); + + println!("[BambuClient::get_device_ips] Connected to MQTT broker"); + + // For each device, subscribe to the topic + for device in devices { + let topic_string = format!("device/{}/status", device.dev_id); + let topic = topic_string.as_str(); + + println!( + "[BambuClient::get_device_ips] Starting discovery for device {} with topic {}", + device.dev_id, topic + ); + + println!( + "[BambuClient::get_device_ips] Subscribing to topic {}...", + topic + ); + + mqtt_client.subscribe(topic, 1).wait().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to subscribe to topic {}: {}", topic, e), + ) + })?; + + // Publish the hello message to the topic to trigger the device to send its IP + let msg = + paho_mqtt::Message::new(topic, constants::BAMBU_MQTT_INIT_PAYLOAD.to_string(), 1); + + mqtt_client.publish(msg).await.map_err(|e| { + // painc here + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to publish to topic {}: {}", topic, e), + ) + })?; + + println!( + "[BambuClient::get_device_ips] Published to topic {}. Moving on to the next device...", + topic + ); + } + + // Allow the client to receive messages for 5 seconds + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + + // Disconnect from the MQTT broker + mqtt_client.disconnect(None).await.map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to disconnect from MQTT broker: {}", e), + ) + })?; + + println!("[BambuClient::get_device_ips] Disconnected from MQTT broker. Done."); + Ok(vec![]) + } } diff --git a/src-tauri/src/handlers/config/mod.rs b/src-tauri/src/handlers/config/mod.rs index 5bee5c5..6804e8a 100644 --- a/src-tauri/src/handlers/config/mod.rs +++ b/src-tauri/src/handlers/config/mod.rs @@ -1,3 +1,4 @@ +use super::bambu::BambuDevice; use serde::{Deserialize, Serialize}; use std::fs::{self, File}; use std::io::{self, Write}; @@ -16,6 +17,7 @@ pub struct BambuInfo { pub struct Config { pub is_first_run: bool, pub bambu_info: BambuInfo, + pub bambu_devices: Vec, } impl Default for Config { @@ -29,6 +31,7 @@ impl Default for Config { jwt_last_refresh: 0, jwt_expires_at: 0, }, + bambu_devices: Vec::new(), } } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 3ca2c84..ee20fb7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,7 +4,7 @@ mod commands; mod constants; mod handlers; -use commands::bambu::login_to_bambu; +use commands::bambu::{discover_devices, fetch_devices, get_jwt, login_to_bambu, set_jwt}; use commands::config::{get_config, init_config, save_config}; use commands::util::quit; @@ -15,7 +15,11 @@ fn main() { get_config, save_config, quit, - login_to_bambu + login_to_bambu, + set_jwt, + get_jwt, + fetch_devices, + discover_devices ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/types.ts b/src/lib/types.ts index 48253a6..3dd5be5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,3 +10,26 @@ export type BambuInfo = { jwt_expires_at: number; jwt_last_refresh: number; }; + +export type BambuLoginResponse = { + token: string; + refresh_token: string; +}; + +export type BambuDevicesResponse = { + message: string; + code?: string; + error?: string; + devices: Device[]; +}; + +export type Device = { + dev_id: string; + name: string; + online: boolean; + print_status: string; + dev_model_name: string; + dev_product_name: string; + dev_access_code: string; + nozzle_diameter: number; +}; diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte index e587f90..513524c 100644 --- a/src/routes/setup/+page.svelte +++ b/src/routes/setup/+page.svelte @@ -16,6 +16,7 @@ import { dialog, clipboard } from '@tauri-apps/api'; import { awaiter } from '$lib/utils'; import { onMount } from 'svelte'; + import type { BambuDevicesResponse, BambuLoginResponse } from '$lib/types'; onMount(async () => { const [config, configError] = await awaiter(invoke('get_config')); @@ -34,6 +35,64 @@ loading = false; }); + async function loginToBambuAndFetchData() { + const [loginResponseRaw, loginError] = await awaiter( + invoke('login_to_bambu', { username, password }) as Promise + ); + + if (loginError || !loginResponseRaw) { + status = 'Failed to authenticate with Bambu'; + await dialog.message( + `Something went wrong while authenticating with Bambu. Please ensure you entered the correct username and password. We've copied the error to your clipboard. Please report this issue on GitHub.\n\nError: ${loginError ?? 'Login response was null'}\n\n`, + { title: 'BambuConnect | Authentication Error', type: 'error' } + ); + + step = 1; + return; + } + + const loginResponse = JSON.parse(loginResponseRaw) as BambuLoginResponse; + console.log( + `[setup] got login response from rust. Response: ${JSON.stringify(loginResponse, null, 2)}` + ); + + status = 'Authenticated with Bambu. Fetching devices...'; + const [_, setJwtError] = await awaiter(invoke('set_jwt', { jwt: loginResponse.token })); + const [devicesRaw, devicesError] = await awaiter(invoke('fetch_devices') as Promise); + + if (setJwtError || devicesError || !devicesRaw) { + status = 'Failed to fetch devices from Bambu'; + await dialog.message( + `Something went wrong while fetching devices from Bambu. We've copied the error to your clipboard. Please report this issue on GitHub.\n\nError: ${setJwtError ?? devicesError ?? 'Unknown error occurred'}\n\n`, + { title: 'BambuConnect | Fetch Devices Error', type: 'error' } + ); + + step = 1; + return; + } + + const devices = JSON.parse(devicesRaw) as BambuDevicesResponse; + console.log(`[setup] got devices from rust. Response: ${JSON.stringify(devices, null, 2)}`); + + status = `Found ${devices.devices.length} devices. Discovering... (This may take a while)`; + const [discovery, discoveryError] = await awaiter( + invoke('discover_devices', { devices: devices.devices }) + ); + + if (discoveryError || !discovery || discovery === null) { + status = 'Failed to discover devices'; + await dialog.message( + `Something went wrong while discovering devices. We've copied the error to your clipboard. Please report this issue on GitHub.\n\nError: ${discoveryError ?? 'Discovery response was null'}\n\n`, + { title: 'BambuConnect | Discovery Error', type: 'error' } + ); + + step = 1; + return; + } + + status = 'Discovery complete. Saving devices...'; + } + function validateLoginForm() { if (!username) { validationErrors.username = 'Username is required'; @@ -49,6 +108,7 @@ if (username && password) { step = 2; + loginToBambuAndFetchData(); } } @@ -180,6 +240,31 @@ Authenticate {/if} + {:else if step == 2} +
+ + Loading... +
+ +

Authenticating with Bambu...

+

+ {status} +

{/if} {/if}