ccs: disable from web

This commit is contained in:
Alex Janka 2024-12-30 20:30:45 +11:00
parent ec8bbdbd41
commit ad8a7b253f
8 changed files with 416 additions and 53 deletions

1
Cargo.lock generated
View file

@ -246,6 +246,7 @@ dependencies = [
"env_logger",
"eyre",
"futures",
"include_dir",
"lazy_static",
"log",
"notify-debouncer-mini",

View file

@ -17,6 +17,7 @@ clap = { version = "4.5.23", features = ["derive"] }
env_logger = "0.11.6"
eyre = "0.6.12"
futures = "0.3.31"
include_dir = "0.7.4"
lazy_static = "1.5.0"
log = "0.4.22"
notify-debouncer-mini = { version = "0.5.0", default-features = false }

View file

@ -1,3 +1,5 @@
#![feature(let_chains)]
use clap::Parser;
use futures::StreamExt;
use std::path::PathBuf;
@ -67,49 +69,53 @@ async fn run() -> eyre::Result<()> {
async fn watch(args: Args) -> eyre::Result<()> {
let _w = config::init_config(&args.config);
let config = config::access_config().await;
if config
.charge_controllers
.iter()
.any(|cc| cc.follow_primary && cc.name == config.primary_charge_controller)
{
return Err(eyre::eyre!(
"primary charge controller is set to follow primary!"
));
}
let mut controllers = Vec::new();
let mut map = std::collections::HashMap::new();
let mut follow_voltage_tx = Vec::new();
for config in &config.charge_controllers {
let n = config.name.clone();
match controller::Controller::new(config.clone()) {
Ok((v, voltage_tx)) => {
map.insert(n, v.get_data_ptr());
controllers.push(v);
if let Some(voltage_tx) = voltage_tx {
follow_voltage_tx.push(voltage_tx);
}
}
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
let (map, mut controller_tasks) = {
let config = config::access_config().await;
if config
.charge_controllers
.iter()
.any(|cc| cc.follow_primary && cc.name == config.primary_charge_controller)
{
return Err(eyre::eyre!(
"primary charge controller is set to follow primary!"
));
}
}
if let Some(primary) = controllers
.iter_mut()
.find(|c| c.name() == config.primary_charge_controller)
{
primary.set_tx_to_secondary(follow_voltage_tx);
}
let mut controllers = Vec::new();
let mut map = std::collections::HashMap::new();
let mut follow_voltage_tx = Vec::new();
let mut controller_tasks = futures::stream::FuturesUnordered::new();
for controller in controllers {
controller_tasks.push(run_loop(controller));
}
for config in &config.charge_controllers {
let n = config.name.clone();
match controller::Controller::new(config.clone()) {
Ok((v, voltage_tx)) => {
map.insert(n, v.get_data_ptr());
controllers.push(v);
if let Some(voltage_tx) = voltage_tx {
follow_voltage_tx.push(voltage_tx);
}
}
Err(e) => log::error!("couldn't connect to {}: {e:?}", n),
}
}
if let Some(primary) = controllers
.iter_mut()
.find(|c| c.name() == config.primary_charge_controller)
{
primary.set_tx_to_secondary(follow_voltage_tx);
}
let controller_tasks = futures::stream::FuturesUnordered::new();
for controller in controllers {
controller_tasks.push(run_loop(controller));
}
(map, controller_tasks)
};
let server = web::rocket(web::ServerState::new(
&config.primary_charge_controller,
&config::access_config().await.primary_charge_controller,
map,
));
let server_task = tokio::task::spawn(server.launch());
@ -128,6 +134,7 @@ async fn watch(args: Args) -> eyre::Result<()> {
v = server_task => {
if let Err(e)=v {
log::error!("server exited: {e:#?}");
std::process::exit(1);
} else {
std::process::exit(0);
}

View file

@ -1,5 +1,7 @@
use rocket::{get, post, routes, serde::json::Json, State};
mod static_handler;
pub struct ServerState {
primary_name: String,
map: std::collections::HashMap<
@ -22,18 +24,35 @@ impl ServerState {
}
pub fn rocket(state: ServerState) -> rocket::Rocket<rocket::Build> {
rocket::build().manage(state).mount(
"/",
routes![
metrics,
interfaces,
all_interfaces,
primary_interface,
interface,
enable_control,
disable_control
],
)
// serve the html from disk if running in a debug build
// this allows editing the webpage without having to rebuild the executable
// but in release builds, bundle the entire webapp folder into the exe
let fileserver: Vec<rocket::Route> = if cfg!(debug_assertions) {
rocket::fs::FileServer::from(format!(
"{}/webapp",
std::env::var("CARGO_MANIFEST_DIR").unwrap()
))
.into()
} else {
static_handler::UiStatic {}.into()
};
rocket::build()
.attach(Cors)
.manage(state)
.mount("/", fileserver)
.mount(
"/",
routes![
metrics,
interfaces,
all_interfaces,
primary_interface,
interface,
get_control,
enable_control,
disable_control
],
)
}
#[get("/interfaces")]
@ -92,7 +111,20 @@ fn metrics() -> Result<String, ServerError> {
)
}
#[post("/enable-control")]
#[derive(serde::Serialize)]
struct ControlState {
enabled: bool,
}
#[get("/control")]
async fn get_control() -> Json<ControlState> {
let enabled = crate::config::access_config()
.await
.enable_secondary_control;
Json(ControlState { enabled })
}
#[post("/control/enable")]
async fn enable_control() {
log::warn!("enabling control");
crate::config::write_to_config()
@ -100,14 +132,13 @@ async fn enable_control() {
.enable_secondary_control = true;
}
#[post("/disable-control")]
#[post("/control/disable")]
async fn disable_control() {
log::warn!("disabling control");
crate::config::write_to_config()
.await
.enable_secondary_control = false;
}
enum ServerError {
Prometheus,
NotFound,
@ -129,3 +160,38 @@ impl<'a> rocket::response::Responder<'a, 'a> for ServerError {
})
}
}
pub struct Cors;
#[rocket::async_trait]
impl rocket::fairing::Fairing for Cors {
fn info(&self) -> rocket::fairing::Info {
rocket::fairing::Info {
name: "Add CORS headers to responses",
kind: rocket::fairing::Kind::Response,
}
}
async fn on_response<'r>(
&self,
_request: &'r rocket::Request<'_>,
response: &mut rocket::Response<'r>,
) {
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Origin",
"*",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Methods",
"POST, GET, PATCH, OPTIONS",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Headers",
"*",
));
response.set_header(rocket::http::Header::new(
"Access-Control-Allow-Credentials",
"true",
));
}
}

View file

@ -0,0 +1,81 @@
use include_dir::{include_dir, Dir};
use rocket::{
http::{ContentType, Status},
outcome::IntoOutcome,
response::Responder,
route::{Handler, Outcome},
Data, Request,
};
use std::path::PathBuf;
static UI_DIR_FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/webapp");
#[derive(Clone, Copy, Debug)]
pub(super) struct UiStatic {}
impl From<UiStatic> for Vec<rocket::Route> {
fn from(server: UiStatic) -> Self {
vec![rocket::Route::ranked(
None,
rocket::http::Method::Get,
"/<path..>",
server,
)]
}
}
#[rocket::async_trait]
impl Handler for UiStatic {
async fn handle<'r>(&self, req: &'r Request<'_>, data: Data<'r>) -> Outcome<'r> {
use rocket::http::uri::fmt::Path;
let path = req
.segments::<rocket::http::uri::Segments<'_, Path>>(0..)
.ok()
.and_then(|segments| segments.to_path_buf(true).ok());
match path {
Some(p) => {
if p.as_os_str() == "" {
let index = UI_DIR_FILES.get_file("index.html").map(|v| RawHtml {
data: v.contents().to_vec(),
name: PathBuf::from("index.html"),
});
index.respond_to(req).or_forward((data, Status::NotFound))
} else {
let plus_index = p.join("index.html");
let file = UI_DIR_FILES
.get_file(&p)
.map(|v| RawHtml {
data: v.contents().to_vec(),
name: p,
})
.or(UI_DIR_FILES.get_file(&plus_index).map(|v| RawHtml {
data: v.contents().to_vec(),
name: plus_index,
}));
file.respond_to(req).or_forward((data, Status::NotFound))
}
}
None => Outcome::forward(data, Status::NotFound),
}
}
}
struct RawHtml {
data: Vec<u8>,
name: PathBuf,
}
impl<'r> Responder<'r, 'static> for RawHtml {
fn respond_to(self, request: &'r Request<'_>) -> rocket::response::Result<'static> {
let mut response = self.data.respond_to(request)?;
if let Some(ext) = self.name.extension()
&& let Some(ct) = ContentType::from_extension(&ext.to_string_lossy())
{
response.set_header(ct);
}
Ok(response)
}
}

View file

@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Charge Control Control</title>
<link id="favicon" rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤯</text></svg>">
<link rel="stylesheet" href="style.css">
<script src="script.js"></script>
</head>
<body onload="init_main()">
<div class="container">
<h3>Follow primary controller:</h3>
<div class="selector" id="control-selector">
<input id="control-enabled" type="radio" name="control" onclick="enable_automatic_control()">
<label for="control-enabled">enabled</label>
<input id="control-disabled" type="radio" name="control" onclick="disable_automatic_control()">
<label for="control-disabled">disabled</label>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,80 @@
const api_url =
window.location.protocol +
"//" +
window.location.hostname +
":" +
window.location.port;
const delay = (time) => {
return new Promise((resolve) => setTimeout(resolve, time));
};
Object.prototype.disable = function () {
var that = this;
for (var i = 0, len = that.length; i < len; i++) {
that[i].disabled = true;
}
return that;
};
Object.prototype.enable = function () {
var that = this;
for (var i = 0, len = that.length; i < len; i++) {
that[i].disabled = false;
}
return that;
};
function init_main() {
refresh_buttons();
}
function disable_automatic_control() {
if (is_automatic_control) {
document.getElementById("control-disabled").checked = true;
document.body.classList.add("loading");
fetch(api_url + "/control/disable", { method: "POST" }).then(
async (response) => {
let delayres = await delay(100);
refresh_buttons();
}
);
}
}
function enable_automatic_control() {
if (!is_automatic_control) {
document.getElementById("control-enabled").checked = true;
document.body.classList.add("loading");
fetch(api_url + "/control/enable", { method: "POST" }).then(
async (response) => {
let delayres = await delay(100);
refresh_buttons();
}
);
}
}
function update_control_buttons(data) {
document.body.classList.remove("loading");
console.log("got data");
console.log(data);
is_automatic_control = data.enabled;
if (is_automatic_control) {
document.getElementById("control-enabled").checked = true;
} else {
document.getElementById("control-disabled").checked = true;
}
}
function refresh_buttons() {
console.log("get data");
console.log(api_url);
fetch(api_url + "/control").then(async (response) => {
console.log("got response");
json = await response.json();
console.log(json);
update_control_buttons(json);
});
}

View file

@ -0,0 +1,101 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: #333;
color: #333;
}
.container {
max-width: 40rem;
margin: auto;
padding: 0.5rem 2rem 2rem 2rem;
border-radius: 10px;
background-color: #faf9fd;
}
.outlink {
display: block;
font-weight: bold;
margin-top: 0.5rem;
}
a.outlink {
text-decoration: none;
color: rgb(52, 52, 246);
}
a.outlink:hover {
color: rgb(110, 100, 255);
}
.loading,
.loading * {
cursor: progress;
}
.disabled,
.disabled * {
cursor: not-allowed;
}
.selector {
padding: 1rem;
background-color: gray;
color: #333;
width: max-content;
border: 0.2rem;
border-radius: 6px;
}
label {
padding: 0.5rem 1rem;
margin: 0.5rem;
font-weight: bold;
transition: all 0.2s 0s ease;
border-radius: 4px;
text-align: center;
}
input[type="radio"] {
display: none;
}
input[type="radio"]:checked + label {
background-color: white;
}
input[type="radio"]:checked:disabled + label {
background-color: #ddd;
}
input[type="radio"]:disabled + label {
color: #666;
}
@media (width > 600px) {
.container {
margin-top: 2rem;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #191919;
}
.container {
background-color: #444;
color: #f0f0f0;
}
a.outlink {
text-decoration: none;
/* color: rgb(152, 152, 242); */
color: rgb(125, 125, 250);
/* color: rgb(94, 94, 252); */
}
a.outlink:hover {
color: rgb(130, 120, 255);
}
}