2024-01-16 11:00:11 +11:00
|
|
|
use metrics::{describe_gauge, gauge, Gauge, Label, Unit};
|
2024-01-08 12:00:09 +11:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use std::{
|
|
|
|
path::PathBuf,
|
|
|
|
sync::{Arc, RwLock},
|
|
|
|
time::{Duration, Instant},
|
|
|
|
};
|
|
|
|
use teslatte::{
|
|
|
|
auth::{AccessToken, RefreshToken},
|
2024-01-16 11:00:11 +11:00
|
|
|
vehicles::{ChargingState, Endpoint, GetVehicleData, SetChargingAmps},
|
2024-01-15 09:37:17 +11:00
|
|
|
FleetApi, FleetVehicleApi,
|
2024-01-08 12:00:09 +11:00
|
|
|
};
|
|
|
|
|
|
|
|
use crate::{errors::*, types::CarState};
|
|
|
|
|
2024-01-09 11:11:16 +11:00
|
|
|
struct Metrics {
|
|
|
|
battery_level: Gauge,
|
|
|
|
charge_rate: Gauge,
|
2024-01-09 12:06:18 +11:00
|
|
|
charge_request: Gauge,
|
2024-01-15 15:47:13 +11:00
|
|
|
charge_enable_request: Gauge,
|
|
|
|
charger_connected: Gauge,
|
2024-01-09 11:11:16 +11:00
|
|
|
inside_temp: Gauge,
|
2024-01-09 12:06:18 +11:00
|
|
|
outside_temp: Gauge,
|
|
|
|
battery_heater: Gauge,
|
2024-01-15 09:42:58 +11:00
|
|
|
climate_on: Gauge,
|
|
|
|
preconditioning: Gauge,
|
|
|
|
remote_heater_control_enabled: Gauge,
|
2024-01-10 20:30:27 +11:00
|
|
|
tesla_online: Gauge,
|
2024-01-16 11:00:11 +11:00
|
|
|
charging_state: ChargingStateGauges,
|
2024-01-09 11:11:16 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
impl Metrics {
|
2024-01-10 15:22:28 +11:00
|
|
|
fn new() -> Self {
|
2024-01-09 11:11:16 +11:00
|
|
|
describe_gauge!("tesla_battery_level", Unit::Percent, "Battery level");
|
|
|
|
let battery_level = gauge!("tesla_battery_level");
|
|
|
|
describe_gauge!("tesla_charge_rate", "Charge rate");
|
|
|
|
let charge_rate = gauge!("tesla_charge_rate");
|
2024-01-09 12:06:18 +11:00
|
|
|
describe_gauge!("tesla_charge_request", "Requested charge rate");
|
|
|
|
let charge_request = gauge!("tesla_charge_request");
|
2024-01-15 15:47:13 +11:00
|
|
|
describe_gauge!("tesla_charge_enable_request", "Charge enable request");
|
|
|
|
let charge_enable_request = gauge!("tesla_charge_enable_request");
|
|
|
|
describe_gauge!("tesla_charger_connected", "Charger connected");
|
|
|
|
let charger_connected = gauge!("tesla_charger_connected");
|
2024-01-09 11:11:16 +11:00
|
|
|
describe_gauge!("tesla_inside_temp", "Inside temperature");
|
|
|
|
let inside_temp = gauge!("tesla_inside_temp");
|
2024-01-09 12:06:18 +11:00
|
|
|
describe_gauge!("tesla_outside_temp", "Outside temperature");
|
|
|
|
let outside_temp = gauge!("tesla_outside_temp");
|
|
|
|
describe_gauge!("tesla_battery_heater", "Battery heater");
|
|
|
|
let battery_heater = gauge!("tesla_battery_heater");
|
2024-01-15 09:42:58 +11:00
|
|
|
describe_gauge!("tesla_climate_on", "Climate control");
|
|
|
|
let climate_on = gauge!("tesla_climate_on");
|
|
|
|
describe_gauge!("tesla_preconditioning", "Preconditioning");
|
|
|
|
let preconditioning = gauge!("tesla_preconditioning");
|
|
|
|
describe_gauge!(
|
|
|
|
"tesla_remote_heater_control_enabled",
|
|
|
|
"Remote heater control enabled"
|
|
|
|
);
|
|
|
|
let remote_heater_control_enabled = gauge!("tesla_remote_heater_control_enabled");
|
2024-01-10 20:30:27 +11:00
|
|
|
describe_gauge!("tesla_online", "Tesla online");
|
|
|
|
let tesla_online = gauge!("tesla_online");
|
2024-01-16 11:00:11 +11:00
|
|
|
describe_gauge!("tesla_charging_state", "Tesla charging state");
|
|
|
|
let charging_state = ChargingStateGauges::new();
|
2024-01-09 11:11:16 +11:00
|
|
|
|
2024-01-10 15:22:28 +11:00
|
|
|
Self {
|
|
|
|
battery_level,
|
|
|
|
charge_rate,
|
|
|
|
charge_request,
|
2024-01-15 15:47:13 +11:00
|
|
|
charge_enable_request,
|
|
|
|
charger_connected,
|
2024-01-10 15:22:28 +11:00
|
|
|
inside_temp,
|
|
|
|
outside_temp,
|
|
|
|
battery_heater,
|
2024-01-15 09:42:58 +11:00
|
|
|
climate_on,
|
|
|
|
preconditioning,
|
|
|
|
remote_heater_control_enabled,
|
2024-01-10 20:30:27 +11:00
|
|
|
tesla_online,
|
2024-01-16 11:00:11 +11:00
|
|
|
charging_state,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct ChargingStateGauges {
|
|
|
|
charging: Gauge,
|
|
|
|
stopped: Gauge,
|
|
|
|
disconnected: Gauge,
|
|
|
|
other: Gauge,
|
|
|
|
}
|
|
|
|
|
|
|
|
impl ChargingStateGauges {
|
|
|
|
fn new() -> Self {
|
|
|
|
let charging = gauge!(
|
|
|
|
"tesla_charging_state",
|
|
|
|
vec![Label::new("state", String::from("charging"))]
|
|
|
|
);
|
|
|
|
let stopped = gauge!(
|
|
|
|
"tesla_charging_state",
|
|
|
|
vec![Label::new("state", String::from("stopped"))]
|
|
|
|
);
|
|
|
|
let disconnected = gauge!(
|
|
|
|
"tesla_charging_state",
|
|
|
|
vec![Label::new("state", String::from("disconnected"))]
|
|
|
|
);
|
|
|
|
let other = gauge!(
|
|
|
|
"tesla_charging_state",
|
|
|
|
vec![Label::new("state", String::from("other"))]
|
|
|
|
);
|
|
|
|
Self {
|
|
|
|
charging,
|
|
|
|
stopped,
|
|
|
|
disconnected,
|
|
|
|
other,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn set(&mut self, state: ChargingState) {
|
|
|
|
match state {
|
|
|
|
ChargingState::Charging => {
|
|
|
|
self.charging.set(1.);
|
|
|
|
self.stopped.set(0.);
|
|
|
|
self.disconnected.set(0.);
|
|
|
|
self.other.set(0.);
|
|
|
|
}
|
|
|
|
ChargingState::Stopped => {
|
|
|
|
self.charging.set(0.);
|
|
|
|
self.stopped.set(1.);
|
|
|
|
self.disconnected.set(0.);
|
|
|
|
self.other.set(0.);
|
|
|
|
}
|
|
|
|
ChargingState::Disconnected => {
|
|
|
|
self.charging.set(0.);
|
|
|
|
self.stopped.set(0.);
|
|
|
|
self.disconnected.set(1.);
|
|
|
|
self.other.set(0.);
|
|
|
|
}
|
|
|
|
ChargingState::Other => {
|
|
|
|
self.charging.set(0.);
|
|
|
|
self.stopped.set(0.);
|
|
|
|
self.disconnected.set(0.);
|
|
|
|
self.other.set(1.);
|
|
|
|
}
|
2024-01-10 15:22:28 +11:00
|
|
|
}
|
2024-01-09 11:11:16 +11:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-08 12:00:09 +11:00
|
|
|
pub struct TeslaInterface {
|
|
|
|
pub state: Arc<RwLock<CarState>>,
|
|
|
|
api: FleetApi,
|
|
|
|
vehicle: Box<teslatte::vehicles::VehicleData>,
|
|
|
|
last_refresh: Instant,
|
|
|
|
auth_path: PathBuf,
|
2024-01-09 11:11:16 +11:00
|
|
|
metrics: Metrics,
|
2024-01-15 13:19:53 +11:00
|
|
|
last_conn_charge_cable: String,
|
2024-01-10 11:51:30 +11:00
|
|
|
last_cop_state: String,
|
2024-01-15 09:37:17 +11:00
|
|
|
last_climate_keeper: String,
|
|
|
|
last_hvac_auto: String,
|
2024-01-08 12:00:09 +11:00
|
|
|
}
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
|
|
struct AuthInfo {
|
|
|
|
access_token: AccessToken,
|
|
|
|
refresh_token: Option<RefreshToken>,
|
|
|
|
}
|
|
|
|
|
2024-01-08 12:16:25 +11:00
|
|
|
#[derive(Clone, Copy, Debug)]
|
2024-01-09 11:50:37 +11:00
|
|
|
// these are the messages that the webserver can send the api thread
|
2024-01-08 12:16:25 +11:00
|
|
|
pub enum InterfaceRequest {
|
|
|
|
FlashLights,
|
2024-01-16 11:00:11 +11:00
|
|
|
StopCharge,
|
|
|
|
SetChargeRate(i64),
|
2024-01-08 12:16:25 +11:00
|
|
|
}
|
|
|
|
|
2024-01-11 08:16:12 +11:00
|
|
|
const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
2024-01-08 12:00:09 +11:00
|
|
|
|
|
|
|
impl TeslaInterface {
|
|
|
|
pub async fn load(auth_path: PathBuf) -> Result<Self, AuthLoadError> {
|
|
|
|
let key: AuthInfo = ron::from_str(&std::fs::read_to_string(&auth_path)?)?;
|
|
|
|
let mut api = FleetApi::new(key.access_token, key.refresh_token);
|
|
|
|
api.refresh().await?;
|
|
|
|
let last_refresh = Instant::now();
|
2024-01-09 11:33:11 +11:00
|
|
|
info!("Refreshed auth key");
|
2024-01-08 12:00:09 +11:00
|
|
|
let vehicle = api
|
|
|
|
.products()
|
|
|
|
.await?
|
|
|
|
.into_iter()
|
|
|
|
.filter_map(|v| match v {
|
|
|
|
teslatte::products::Product::Vehicle(vehicle) => Some(vehicle),
|
|
|
|
_ => None,
|
|
|
|
})
|
|
|
|
.next()
|
2024-01-12 08:47:21 +11:00
|
|
|
.ok_or(AuthLoadError::NoVehicles)?;
|
2024-01-08 12:00:09 +11:00
|
|
|
|
2024-01-10 15:22:28 +11:00
|
|
|
let metrics = Metrics::new();
|
2024-01-09 11:11:16 +11:00
|
|
|
|
2024-01-08 12:00:09 +11:00
|
|
|
let interface = Self {
|
|
|
|
state: Arc::new(RwLock::new(Default::default())),
|
|
|
|
api,
|
|
|
|
last_refresh,
|
|
|
|
auth_path,
|
|
|
|
vehicle,
|
2024-01-09 11:11:16 +11:00
|
|
|
metrics,
|
2024-01-15 13:19:53 +11:00
|
|
|
last_conn_charge_cable: String::new(),
|
2024-01-10 11:51:30 +11:00
|
|
|
last_cop_state: String::new(),
|
2024-01-15 09:37:17 +11:00
|
|
|
last_climate_keeper: String::new(),
|
|
|
|
last_hvac_auto: String::new(),
|
2024-01-08 12:00:09 +11:00
|
|
|
};
|
|
|
|
interface.save_key()?;
|
|
|
|
|
|
|
|
Ok(interface)
|
|
|
|
}
|
|
|
|
|
|
|
|
fn save_key(&self) -> Result<(), SaveError> {
|
|
|
|
std::fs::write(
|
|
|
|
self.auth_path.clone(),
|
|
|
|
ron::ser::to_string(&AuthInfo {
|
|
|
|
access_token: self.api.access_token.clone(),
|
|
|
|
refresh_token: self.api.refresh_token.clone(),
|
|
|
|
})?,
|
|
|
|
)?;
|
2024-01-09 11:33:11 +11:00
|
|
|
info!("Auth successfully saved");
|
2024-01-08 12:00:09 +11:00
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
pub async fn refresh(&mut self) {
|
2024-01-10 11:51:30 +11:00
|
|
|
info!("refreshing...");
|
2024-01-08 12:00:09 +11:00
|
|
|
self.refresh_keys().await;
|
|
|
|
self.refresh_state().await;
|
|
|
|
}
|
|
|
|
|
2024-01-08 12:16:25 +11:00
|
|
|
pub async fn process_request(&mut self, request: InterfaceRequest) {
|
|
|
|
match request {
|
|
|
|
InterfaceRequest::FlashLights => {
|
|
|
|
let _ = self.api.flash_lights(&self.vehicle.vin).await;
|
|
|
|
}
|
2024-01-16 11:00:11 +11:00
|
|
|
InterfaceRequest::StopCharge => match self.api.charge_stop(&self.vehicle.vin).await {
|
|
|
|
Ok(_) => log::warn!("Successfully stopped charge"),
|
|
|
|
Err(e) => log::error!("Error stopping charge: {e:?}"),
|
|
|
|
},
|
|
|
|
InterfaceRequest::SetChargeRate(charging_amps) => match self
|
|
|
|
.api
|
|
|
|
.set_charging_amps(&self.vehicle.vin, &SetChargingAmps { charging_amps })
|
|
|
|
.await
|
|
|
|
{
|
|
|
|
Ok(_) => log::warn!("Successfully set charge rate to {charging_amps}"),
|
|
|
|
Err(e) => log::error!("Error setting charge rate: {e:?}"),
|
|
|
|
},
|
2024-01-08 12:16:25 +11:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-08 12:00:09 +11:00
|
|
|
async fn refresh_state(&mut self) {
|
2024-01-15 09:37:17 +11:00
|
|
|
match self.get_state().await {
|
2024-01-08 12:00:09 +11:00
|
|
|
Ok(new_state) => {
|
2024-01-10 20:30:27 +11:00
|
|
|
self.metrics.tesla_online.set(1.);
|
2024-01-11 10:28:01 +11:00
|
|
|
let mut state = self
|
|
|
|
.state
|
|
|
|
.write()
|
|
|
|
.expect("Tesla API state handler panicked!!");
|
2024-01-08 12:00:09 +11:00
|
|
|
|
|
|
|
if let Some(new_charge_state) = new_state.charge_state {
|
2024-01-09 11:11:16 +11:00
|
|
|
self.metrics
|
|
|
|
.battery_level
|
|
|
|
.set(new_charge_state.battery_level as f64);
|
|
|
|
self.metrics.charge_rate.set(new_charge_state.charge_rate);
|
2024-01-09 12:06:18 +11:00
|
|
|
self.metrics
|
|
|
|
.charge_request
|
|
|
|
.set(new_charge_state.charge_current_request as f64);
|
2024-01-15 15:47:13 +11:00
|
|
|
self.metrics
|
|
|
|
.charge_enable_request
|
|
|
|
.set(bf(new_charge_state.charge_enable_request));
|
|
|
|
self.metrics
|
|
|
|
.charger_connected
|
|
|
|
.set(bf(new_charge_state.charger_connected));
|
2024-01-16 11:00:11 +11:00
|
|
|
self.metrics
|
|
|
|
.charging_state
|
|
|
|
.set(new_charge_state.charging_state);
|
2024-01-08 12:00:09 +11:00
|
|
|
state.charge_state = Some(new_charge_state);
|
|
|
|
}
|
|
|
|
if let Some(new_location_data) = new_state.location_data {
|
|
|
|
state.location_data = Some(new_location_data);
|
|
|
|
}
|
2024-01-09 11:11:16 +11:00
|
|
|
if let Some(new_climate_state) = new_state.climate_state {
|
|
|
|
self.metrics.inside_temp.set(new_climate_state.inside_temp);
|
2024-01-09 12:06:18 +11:00
|
|
|
self.metrics
|
|
|
|
.outside_temp
|
|
|
|
.set(new_climate_state.outside_temp);
|
|
|
|
self.metrics
|
|
|
|
.battery_heater
|
2024-01-15 09:42:58 +11:00
|
|
|
.set(bf(new_climate_state.battery_heater));
|
|
|
|
self.metrics
|
|
|
|
.climate_on
|
|
|
|
.set(bf(new_climate_state.climate_on));
|
|
|
|
self.metrics
|
|
|
|
.preconditioning
|
|
|
|
.set(bf(new_climate_state.preconditioning));
|
|
|
|
self.metrics
|
|
|
|
.remote_heater_control_enabled
|
|
|
|
.set(bf(new_climate_state.remote_heater_control_enabled));
|
2024-01-15 15:47:13 +11:00
|
|
|
|
2024-01-09 11:11:16 +11:00
|
|
|
state.climate_state = Some(new_climate_state);
|
|
|
|
}
|
2024-01-08 12:00:09 +11:00
|
|
|
}
|
2024-01-10 20:30:27 +11:00
|
|
|
Err(e) => {
|
|
|
|
self.metrics.tesla_online.set(0.);
|
2024-01-11 08:16:12 +11:00
|
|
|
if let RequestError::Teslatte(teslatte::error::TeslatteError::DecodeJsonError {
|
|
|
|
source: _,
|
|
|
|
request: _,
|
|
|
|
body,
|
|
|
|
}) = &e
|
|
|
|
{
|
|
|
|
if body.contains("vehicle is offline or asleep") {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
2024-01-12 09:09:42 +11:00
|
|
|
|
|
|
|
match e {
|
|
|
|
RequestError::StdIo(e) => log::error!("Stdio error: {e:?}"),
|
|
|
|
RequestError::RonSpanned(e) => log::error!("RON parsing error: {e:?}"),
|
|
|
|
RequestError::Teslatte(teslatte::error::TeslatteError::DecodeJsonError {
|
2024-01-13 15:14:50 +11:00
|
|
|
source,
|
2024-01-12 09:09:42 +11:00
|
|
|
request: _,
|
|
|
|
body,
|
|
|
|
}) => {
|
2024-01-13 15:14:50 +11:00
|
|
|
log::error!("Error {source} getting state: {body}")
|
2024-01-12 09:09:42 +11:00
|
|
|
}
|
|
|
|
RequestError::Teslatte(e) => log::error!("Teslatte error: {e:?}"),
|
|
|
|
RequestError::Save(e) => log::error!("Save error: {e:?}"),
|
|
|
|
}
|
2024-01-10 20:30:27 +11:00
|
|
|
}
|
2024-01-08 12:00:09 +11:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async fn refresh_keys(&mut self) {
|
2024-01-09 11:50:37 +11:00
|
|
|
// refresh our Tesla (the company's web servers, not the car) access token
|
|
|
|
if Instant::now().duration_since(self.last_refresh) >= KEY_REFRESH_INTERVAL {
|
2024-01-12 08:47:19 +11:00
|
|
|
log::warn!("refreshing keys...");
|
2024-01-15 10:13:46 +11:00
|
|
|
if self
|
|
|
|
.api
|
|
|
|
.refresh()
|
|
|
|
.await
|
|
|
|
.some_or_print_with("refreshing key")
|
|
|
|
.is_some()
|
|
|
|
{
|
2024-01-12 09:09:42 +11:00
|
|
|
let now = Instant::now();
|
2024-01-15 10:13:46 +11:00
|
|
|
if self.save_key().some_or_print_with("saving key").is_some() {
|
2024-01-12 09:09:42 +11:00
|
|
|
self.last_refresh = now;
|
2024-01-08 12:00:09 +11:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-15 09:37:17 +11:00
|
|
|
async fn get_state(&mut self) -> Result<CarState, RequestError> {
|
|
|
|
// Endpoint::VehicleDataCombo or multiple Endpoints in one request
|
|
|
|
// doesn't seem to reliably get them all,
|
|
|
|
// so for each endpoint we do a new request
|
|
|
|
let charge_state = self
|
|
|
|
.api
|
|
|
|
.vehicle_data(&GetVehicleData {
|
|
|
|
vehicle_id: self.vehicle.id,
|
|
|
|
endpoints: vec![Endpoint::ChargeState].into(),
|
|
|
|
})
|
|
|
|
.await?
|
|
|
|
.charge_state
|
2024-01-15 13:19:53 +11:00
|
|
|
.map(|v| {
|
|
|
|
if self.last_conn_charge_cable != v.conn_charge_cable {
|
|
|
|
log::warn!("Current conn charge cable: \"{}\"", v.conn_charge_cable);
|
|
|
|
self.last_conn_charge_cable = v.conn_charge_cable.clone();
|
|
|
|
}
|
|
|
|
v.into()
|
|
|
|
});
|
2024-01-08 12:00:09 +11:00
|
|
|
|
2024-01-15 09:37:17 +11:00
|
|
|
let location_data = self
|
|
|
|
.api
|
|
|
|
.vehicle_data(&GetVehicleData {
|
|
|
|
vehicle_id: self.vehicle.id,
|
|
|
|
endpoints: vec![Endpoint::LocationData].into(),
|
|
|
|
})
|
|
|
|
.await?
|
|
|
|
.drive_state
|
|
|
|
.and_then(|v| v.try_into().ok());
|
2024-01-09 11:11:16 +11:00
|
|
|
|
2024-01-15 09:37:17 +11:00
|
|
|
let climate_state = self
|
|
|
|
.api
|
|
|
|
.vehicle_data(&GetVehicleData {
|
|
|
|
vehicle_id: self.vehicle.id,
|
|
|
|
endpoints: vec![Endpoint::ClimateState].into(),
|
|
|
|
})
|
|
|
|
.await?
|
|
|
|
.climate_state
|
|
|
|
.and_then(|v| {
|
|
|
|
if self.last_cop_state != v.cabin_overheat_protection {
|
|
|
|
log::warn!(
|
2024-01-15 13:19:53 +11:00
|
|
|
"Current cabin overheat protection state: \"{}\"",
|
2024-01-15 09:37:17 +11:00
|
|
|
v.cabin_overheat_protection
|
|
|
|
);
|
|
|
|
self.last_cop_state = v.cabin_overheat_protection.clone();
|
|
|
|
}
|
|
|
|
if self.last_climate_keeper != v.climate_keeper_mode {
|
2024-01-15 13:19:53 +11:00
|
|
|
log::warn!("Current climate keeper mode: \"{}\"", v.climate_keeper_mode);
|
2024-01-15 09:37:17 +11:00
|
|
|
self.last_climate_keeper = v.climate_keeper_mode.clone();
|
|
|
|
}
|
|
|
|
if self.last_hvac_auto != v.hvac_auto_request {
|
2024-01-15 13:19:53 +11:00
|
|
|
log::warn!("HVAC auto request set to: \"{}\"", v.hvac_auto_request);
|
2024-01-15 09:37:17 +11:00
|
|
|
self.last_hvac_auto = v.hvac_auto_request.clone();
|
|
|
|
}
|
|
|
|
v.try_into().ok()
|
|
|
|
});
|
2024-01-08 12:00:09 +11:00
|
|
|
|
2024-01-15 09:37:17 +11:00
|
|
|
Ok(CarState {
|
|
|
|
charge_state,
|
|
|
|
location_data,
|
|
|
|
climate_state,
|
|
|
|
})
|
|
|
|
}
|
2024-01-08 12:00:09 +11:00
|
|
|
}
|
2024-01-15 09:42:58 +11:00
|
|
|
|
|
|
|
fn bf(value: bool) -> f64 {
|
|
|
|
if value {
|
|
|
|
1.0
|
|
|
|
} else {
|
|
|
|
0.0
|
|
|
|
}
|
|
|
|
}
|