ccs: disable from web
This commit is contained in:
parent
ec8bbdbd41
commit
ad8a7b253f
8 changed files with 416 additions and 53 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -246,6 +246,7 @@ dependencies = [
|
|||
"env_logger",
|
||||
"eyre",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"notify-debouncer-mini",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
81
charge-controller-supervisor/src/web/static_handler.rs
Normal file
81
charge-controller-supervisor/src/web/static_handler.rs
Normal 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)
|
||||
}
|
||||
}
|
26
charge-controller-supervisor/webapp/index.html
Normal file
26
charge-controller-supervisor/webapp/index.html
Normal 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>
|
80
charge-controller-supervisor/webapp/script.js
Normal file
80
charge-controller-supervisor/webapp/script.js
Normal 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);
|
||||
});
|
||||
}
|
101
charge-controller-supervisor/webapp/style.css
Normal file
101
charge-controller-supervisor/webapp/style.css
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue