automatic control beta 1

This commit is contained in:
Alex Janka 2024-01-16 11:00:11 +11:00
parent 4f29b3fcf8
commit 560fbf3d7a
10 changed files with 321 additions and 20 deletions

2
Cargo.lock generated
View file

@ -2256,7 +2256,7 @@ dependencies = [
[[package]]
name = "tesla-charge-controller"
version = "0.2.8"
version = "1.0.0-beta-1"
dependencies = [
"async-channel",
"chrono",

View file

@ -1,6 +1,6 @@
[package]
name = "tesla-charge-controller"
version = "0.2.8"
version = "1.0.0-beta-1"
edition = "2021"
license = "MITNFA"
description = "Controls Tesla charge rate based on solar charge data"

View file

@ -1,4 +1,4 @@
use metrics::{describe_gauge, gauge, Gauge, Unit};
use metrics::{describe_gauge, gauge, Gauge, Label, Unit};
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
@ -7,7 +7,7 @@ use std::{
};
use teslatte::{
auth::{AccessToken, RefreshToken},
vehicles::{Endpoint, GetVehicleData},
vehicles::{ChargingState, Endpoint, GetVehicleData, SetChargingAmps},
FleetApi, FleetVehicleApi,
};
@ -26,6 +26,7 @@ struct Metrics {
preconditioning: Gauge,
remote_heater_control_enabled: Gauge,
tesla_online: Gauge,
charging_state: ChargingStateGauges,
}
impl Metrics {
@ -57,6 +58,8 @@ impl Metrics {
let remote_heater_control_enabled = gauge!("tesla_remote_heater_control_enabled");
describe_gauge!("tesla_online", "Tesla online");
let tesla_online = gauge!("tesla_online");
describe_gauge!("tesla_charging_state", "Tesla charging state");
let charging_state = ChargingStateGauges::new();
Self {
battery_level,
@ -71,6 +74,70 @@ impl Metrics {
preconditioning,
remote_heater_control_enabled,
tesla_online,
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.);
}
}
}
}
@ -82,7 +149,6 @@ pub struct TeslaInterface {
last_refresh: Instant,
auth_path: PathBuf,
metrics: Metrics,
last_charging_state: String,
last_conn_charge_cable: String,
last_cop_state: String,
last_climate_keeper: String,
@ -99,6 +165,8 @@ struct AuthInfo {
// these are the messages that the webserver can send the api thread
pub enum InterfaceRequest {
FlashLights,
StopCharge,
SetChargeRate(i64),
}
const KEY_REFRESH_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
@ -130,7 +198,6 @@ impl TeslaInterface {
auth_path,
vehicle,
metrics,
last_charging_state: String::new(),
last_conn_charge_cable: String::new(),
last_cop_state: String::new(),
last_climate_keeper: String::new(),
@ -164,6 +231,18 @@ impl TeslaInterface {
InterfaceRequest::FlashLights => {
let _ = self.api.flash_lights(&self.vehicle.vin).await;
}
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:?}"),
},
}
}
@ -190,6 +269,9 @@ impl TeslaInterface {
self.metrics
.charger_connected
.set(bf(new_charge_state.charger_connected));
self.metrics
.charging_state
.set(new_charge_state.charging_state);
state.charge_state = Some(new_charge_state);
}
if let Some(new_location_data) = new_state.location_data {
@ -282,10 +364,6 @@ impl TeslaInterface {
log::warn!("Current conn charge cable: \"{}\"", v.conn_charge_cable);
self.last_conn_charge_cable = v.conn_charge_cable.clone();
}
if self.last_charging_state != v.charging_state {
log::warn!("Current charging state: \"{}\"", v.charging_state);
self.last_charging_state = v.charging_state.clone();
}
v.into()
});

View file

@ -15,18 +15,23 @@ pub fn access_config<'a>() -> &'a Config {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Config {
pub tesla_watch_interval_seconds: u64,
pub tesla_update_interval_seconds: u64,
pub pl_watch_interval_seconds: u64,
pub pl_timeout_milliseconds: u64,
pub coords: Coords,
pub serial_port: String,
pub baud_rate: u32,
pub shutoff_voltage: f64,
pub min_rate: i64,
pub max_rate: i64,
pub duty_cycle_too_high: f64,
pub duty_cycle_too_low: f64,
}
impl Default for Config {
fn default() -> Self {
Self {
tesla_watch_interval_seconds: 120,
tesla_update_interval_seconds: 120,
pl_watch_interval_seconds: 30,
pl_timeout_milliseconds: 250,
coords: Coords {
@ -35,6 +40,11 @@ impl Default for Config {
},
serial_port: String::from("/dev/ttyUSB0"),
baud_rate: 9600,
shutoff_voltage: 52.,
min_rate: 5,
max_rate: 10,
duty_cycle_too_high: 0.9,
duty_cycle_too_low: 0.7,
}
}
}

View file

@ -9,6 +9,7 @@ use clap::{Parser, Subcommand};
use config::{access_config, CONFIG_PATH};
use errors::PrintErrors;
use std::path::PathBuf;
use tesla_charge_rate::TeslaChargeRateController;
use crate::config::Config;
@ -17,6 +18,7 @@ mod charge_controllers;
mod config;
mod errors;
mod server;
mod tesla_charge_rate;
mod types;
#[derive(Parser, Debug, Clone)]
@ -73,6 +75,9 @@ async fn main() {
let (api_requests, api_receiver) = async_channel::unbounded();
// and to the pli thread
let (pli_requests, pli_receiver) = async_channel::unbounded();
// and to the charge rate controller thread
let (tcrc_requests, tcrc_receiver) = async_channel::unbounded();
charge_controllers::register_metrics();
// try to spawn the pli loop
@ -105,25 +110,39 @@ async fn main() {
}
};
let mut tesla_charge_rate_controller =
TeslaChargeRateController::new(interface.state.clone(), pl_state.clone());
let server_handle = server::launch_server(server::ServerState {
car_state: interface.state.clone(),
pl_state,
tcrc_state: tesla_charge_rate_controller.tcrc_state.clone(),
api_requests,
pli_requests,
tcrc_requests,
});
// spawn the api loop
tokio::task::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
config.tesla_watch_interval_seconds,
config.tesla_update_interval_seconds,
));
loop {
// await either the next interval OR a message from the other thread
tokio::select! {
_ = interval.tick() => interface.refresh().await,
message = api_receiver.recv() => match message {
_ = interval.tick() => {
if let Some(request) = tesla_charge_rate_controller.control_charge_rate() {
interface.process_request(request).await;
}
interface.refresh().await
},
api_message = api_receiver.recv() => match api_message {
Ok(message) => interface.process_request(message).await,
Err(e) => panic!("Error on Tesla receive channel: {e:?}")
},
tcrc_message = tcrc_receiver.recv() => match tcrc_message {
Ok(message) => tesla_charge_rate_controller.process_request(message),
Err(e) => panic!("Error on TCRC receive channel: {e:?}")
}
}
}

View file

@ -12,6 +12,7 @@ use crate::{
api_interface::InterfaceRequest,
charge_controllers::pl::{PlState, PliRequest},
errors::ServerError,
tesla_charge_rate::{TcrcRequest, TcrcState},
types::CarState,
};
@ -21,9 +22,11 @@ mod static_handler;
pub struct ServerState {
pub car_state: Arc<RwLock<CarState>>,
pub tcrc_state: Arc<RwLock<TcrcState>>,
pub pl_state: Option<Arc<RwLock<PlState>>>,
pub api_requests: Sender<InterfaceRequest>,
pub pli_requests: Sender<PliRequest>,
pub tcrc_requests: Sender<TcrcRequest>,
}
pub async fn launch_server(state: ServerState) {
@ -49,7 +52,17 @@ fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
.mount("/", fileserver)
.mount(
"/",
routes![home, car_state, regulator_state, flash, metrics, read_ram,],
routes![
home,
car_state,
regulator_state,
control_state,
flash,
disable_control,
enable_control,
metrics,
read_ram
],
)
}
@ -68,11 +81,40 @@ async fn car_state(state: &State<ServerState>) -> Result<Json<CarState>, ServerE
Ok(Json(*state.car_state.read()?))
}
#[get("/control-state")]
async fn control_state(state: &State<ServerState>) -> Result<Json<TcrcState>, ServerError> {
Ok(Json(*state.tcrc_state.read()?))
}
#[post("/flash")]
async fn flash(state: &State<ServerState>) {
let _ = state.api_requests.send(InterfaceRequest::FlashLights).await;
}
#[post("/disable-control")]
async fn disable_control(state: &State<ServerState>) {
match state
.tcrc_requests
.send(TcrcRequest::DisableAutomaticControl)
.await
{
Ok(_) => {}
Err(e) => log::error!("Error sending stop control request: {e:?}"),
}
}
#[post("/enable-control")]
async fn enable_control(state: &State<ServerState>) {
match state
.tcrc_requests
.send(TcrcRequest::EnableAutomaticControl)
.await
{
Ok(_) => {}
Err(e) => log::error!("Error sending stop control request: {e:?}"),
}
}
#[get("/metrics")]
fn metrics() -> Result<String, ServerError> {
Ok(

109
src/tesla_charge_rate.rs Normal file
View file

@ -0,0 +1,109 @@
use std::sync::{Arc, RwLock};
use metrics::{describe_gauge, gauge, Gauge};
use serde::{Deserialize, Serialize};
use teslatte::vehicles::ChargingState;
use crate::{
api_interface::InterfaceRequest,
charge_controllers::pl::PlState,
config::access_config,
types::{CarState, ChargeState},
};
pub struct TeslaChargeRateController {
pub car_state: Arc<RwLock<CarState>>,
pub pl_state: Option<Arc<RwLock<PlState>>>,
pub tcrc_state: Arc<RwLock<TcrcState>>,
control_enable_gauge: Gauge,
}
pub enum TcrcRequest {
DisableAutomaticControl,
EnableAutomaticControl,
}
#[derive(Default, Clone, Copy, Serialize, Deserialize, Debug)]
pub struct TcrcState {
pub control_enable: bool,
}
impl TeslaChargeRateController {
pub fn new(car_state: Arc<RwLock<CarState>>, pl_state: Option<Arc<RwLock<PlState>>>) -> Self {
describe_gauge!("tcrc_control_enable", "Enable Tesla charge rate control");
Self {
car_state,
pl_state,
tcrc_state: Default::default(),
control_enable_gauge: gauge!("tcrc_control_enable"),
}
}
pub fn control_charge_rate(&mut self) -> Option<InterfaceRequest> {
if let Some(pl_state) = self.pl_state.as_ref().and_then(|v| v.read().ok()) {
if let Ok(car_state) = self.car_state.read() {
if let Some(charge_state) = car_state.charge_state {
// check if we're charging at home
if charge_state.charging_state == ChargingState::Charging
&& car_state.location_data.is_some_and(|v| v.home)
{
// automatic control or not, check if we're below shutoff voltage
if pl_state.battery_voltage < access_config().shutoff_voltage {
return Some(InterfaceRequest::StopCharge);
} else if self.tcrc_state.read().is_ok_and(|v| v.control_enable) {
return get_control(&pl_state, &charge_state);
}
} else if charge_state.charging_state == ChargingState::Disconnected {
// only disable automatic control until the next time we're unplugged
self.tcrc_state
.write()
.expect("failed to write to tcrc state")
.control_enable = true;
self.control_enable_gauge.set(1.);
}
}
}
}
None
}
pub fn process_request(&mut self, message: TcrcRequest) {
match message {
TcrcRequest::DisableAutomaticControl => {
self.tcrc_state
.write()
.expect("failed to write to tcrc state")
.control_enable = false;
self.control_enable_gauge.set(0.);
}
TcrcRequest::EnableAutomaticControl => {
self.tcrc_state
.write()
.expect("failed to write to tcrc state")
.control_enable = true;
self.control_enable_gauge.set(1.);
}
}
}
}
fn get_control(pl_state: &PlState, charge_state: &ChargeState) -> Option<InterfaceRequest> {
let config = access_config();
if pl_state.duty_cycle > config.duty_cycle_too_high {
let new_rate = charge_state.charge_amps - 1;
return if new_rate >= config.min_rate {
Some(InterfaceRequest::SetChargeRate(new_rate))
} else {
None
};
} else if pl_state.duty_cycle < config.duty_cycle_too_low {
let new_rate = charge_state.charge_amps + 1;
return if new_rate <= config.max_rate {
Some(InterfaceRequest::SetChargeRate(new_rate))
} else {
None
};
}
None
}

View file

@ -1,5 +1,6 @@
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use teslatte::vehicles::ChargingState;
use crate::{config::access_config, errors::TeslaStateParseError};
@ -45,6 +46,7 @@ pub struct ChargeState {
pub charge_current_request_max: i64,
pub charger_connected: bool,
pub charge_enable_request: bool,
pub charging_state: ChargingState,
pub charge_limit_soc: i64,
}
@ -57,7 +59,6 @@ impl ChargeState {
impl From<teslatte::vehicles::ChargeState> for ChargeState {
fn from(value: teslatte::vehicles::ChargeState) -> Self {
let charger_connected = value.conn_charge_cable != "<invalid>";
ChargeState {
battery_level: value.battery_level,
battery_range: value.battery_range,
@ -65,8 +66,9 @@ impl From<teslatte::vehicles::ChargeState> for ChargeState {
charge_rate: value.charge_rate,
charge_current_request: value.charge_current_request,
charge_current_request_max: value.charge_current_request_max,
charger_connected,
charger_connected: value.conn_charge_cable != "<invalid>",
charge_enable_request: value.charge_enable_request,
charging_state: value.charging_state,
charge_limit_soc: value.charge_limit_soc,
}
}

@ -1 +1 @@
Subproject commit 4888ce9d394e651ad051aeea4ac00d0ae2b75068
Subproject commit 636c5fc4821286555cdab311dd520cd0d08965ce

View file

@ -31,6 +31,45 @@
.then((response) => console.log(response));
}
function disable_automatic_control() {
fetch(api_url + "/disable-control", { method: "POST" })
.then((response) => {
refresh_buttons();
});
}
function enable_automatic_control() {
fetch(api_url + "/enable-control", { method: "POST" })
.then((response) => {
refresh_buttons();
});
}
function update_control_buttons(data) {
console.log(data);
var button_container = document.getElementById("buttons");
while (button_container.childElementCount > 0) { button_container.removeChild(button_container.firstChild) }
if (data.control_enable) {
// control enabled, so show disable button
var button = document.createElement('button');
button.textContent = 'Disable automatic control';
button.addEventListener('click', disable_automatic_control);
button_container.appendChild(button);
} else {
// control disabled, so show enable button
var button = document.createElement('button');
button.textContent = 'Enable automatic control';
button.addEventListener('click', enable_automatic_control);
button_container.appendChild(button);
}
}
function refresh_buttons() {
fetch(api_url + "/control-state")
.then((response) => response.json())
.then((json) => update_control_buttons(json));
}
function refresh() {
let favicon = document.getElementById("favicon");
@ -39,7 +78,7 @@
.then((response) => response.json())
.then((json) => update_state(json));
refresh_buttons();
}
function update_state(state) {
@ -107,6 +146,8 @@
</div>
<button onclick="flash()">flash</button>
<p id="buttons"></p>
<div id="info"></div>
</html>