add user config (closes #3)

This commit is contained in:
Christoph Beckmann 2025-03-22 18:09:54 +01:00
parent 9a2318432a
commit 6df991078f
11 changed files with 808 additions and 377 deletions

837
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ csv = "1.3.0"
chrono = "0.4.33"
serde = { version = "1.0.196", features = ["derive"] }
tap = "1.0.1"
lazy_static = "1.4.0"
toml = "0.8.10"
futures = "0.3.30"
colour = "0.7.0"
config = { version = "0.15.11", features = ["toml"] }

11
spacermake.toml Normal file
View File

@ -0,0 +1,11 @@
SLAVES_BY_MASTER = "master-slave_relations.toml"
SLAVE_PROPERTIES = "slave_properties.toml"
MACHINE_IDS = "/root/fabfire/config.toml"
BILLING_LOG = "billinglog.csv"
MACHINE_LOG = "machinelog.csv"
DEBUG_LOG = "machinelog_debug.csv"
DATA_USER = "DataUser.csv"
DATA_MACHINES = "DataMachines.csv"
MQTT_HOST = "mqtt.makerspace-bocholt.local"
# MQTT_USERNAME = ""
# MQTT_PASSWORD = ""

View File

@ -1,65 +1,39 @@
use std::time::Duration;
use std::collections::{HashMap, HashSet};
use colour::{dark_grey_ln, magenta_ln};
use lazy_static::*;
use rumqttc::{AsyncClient, EventLoop, MqttOptions, QoS};
use state::{Announcer, Listener, State};
use utils::parse_toml_file;
use crate::utils::logs::log_start;
use self::my_config::MyConfig;
pub mod my_config;
mod state;
mod utils;
pub const BOOKING_TOPIC: &str = "fabaccess/log";
lazy_static! {
static ref SLAVES_BY_MASTER: HashMap<String, HashSet<String>> = parse_toml_file("master-slave_relations.toml");
static ref SLAVE_PROPERTIES: HashMap<String, [bool; 3]> = parse_toml_file("slave_properties.toml");
static ref MACHINE_IDS: HashMap<String, String> = parse_toml_file::<toml::Table>("/root/fabfire/config.toml")
["readers"]
.as_table()
.unwrap()
.iter()
.map(|(_key, value)| {
let entry = value.as_table().unwrap();
(
entry["machine"].as_str().unwrap().replace("urn:fabaccess:resource:", ""),
entry["id"].as_str().unwrap().into()
)
})
.collect();
}
#[tokio::main]
async fn main() {
magenta_ln!("===== spacermake =====");
log_start().expect("startup log failed");
print_config();
let (client, event_loop) = create_client().await;
let my_config = MyConfig::load();
dark_grey_ln!("{my_config:#?}");
let (client, event_loop) = create_client(&my_config).await;
magenta_ln!("start");
let listener = State::new(Listener, client);
let listener = State::new(Listener, client, my_config);
let announcer = listener.duplicate_as(Announcer);
tokio::spawn(announcer.run());
listener.run(event_loop).await;
}
fn print_config() {
let slaves_by_master: &HashMap<_, _> = &SLAVES_BY_MASTER;
let slave_properties: &HashMap<_, _> = &SLAVE_PROPERTIES;
let machine_ids: &HashMap<_, _> = &MACHINE_IDS;
let data_machine: &HashMap<_, _> = &utils::logs::billing::DATA_MACHINES;
let data_user: &HashMap<_, _> = &utils::logs::billing::DATA_USER;
dark_grey_ln!("{slaves_by_master:#?}{slave_properties:#?}{machine_ids:#?}{data_machine:#?}{data_user:#?}");
}
async fn create_client() -> (AsyncClient, EventLoop) {
let mut mqttoptions = MqttOptions::new("spacermake", "mqtt.makerspace-bocholt.local", 1883);
async fn create_client(my_config: &MyConfig) -> (AsyncClient, EventLoop) {
let mut mqttoptions = MqttOptions::new("spacermake", &my_config.mqtt_host, 1883);
mqttoptions.set_keep_alive(Duration::from_secs(5));
if let (Some(username), Some(password)) = (&my_config.mqtt_username, &my_config.mqtt_password) {
mqttoptions.set_credentials(username, password);
}
let (client, event_loop) = AsyncClient::new(mqttoptions, 10);
client.subscribe("tele/+/MARGINS", QoS::AtMostOnce).await.expect("failed to subscribe");

134
src/my_config.rs Normal file
View File

@ -0,0 +1,134 @@
use std::io::Read;
use std::fs::File;
use std::collections::{HashMap, HashSet};
use config::Config;
use tap::Pipe;
#[derive(Debug)]
pub struct MyConfig {
pub slaves_by_master: HashMap<String, HashSet<String>>,
pub slave_properties: HashMap<String, [bool; 3]>,
pub machine_ids : HashMap<String, String>,
pub data_user : HashMap<String, UserData>,
pub data_machines : HashMap<String, MachineData>,
pub billing_log : String,
pub machine_log : String,
pub debug_log : String,
pub mqtt_host : String,
pub mqtt_username : Option<String>,
pub mqtt_password : Option<String>,
}
#[derive(Debug)]
pub struct UserData {
pub id: Option<i32>,
pub to_be_used: bool
}
#[derive(Debug)]
pub struct MachineData {
pub id: Option<i32>,
pub to_be_used: bool,
pub power_sense: bool, //1 = runtime, 0 = booked time
pub divider: i32
}
impl MyConfig {
pub fn load() -> Self {
let config = Config::builder()
.add_source(config::File::with_name("spacermake"))
.add_source(config::Environment::default())
.build()
.expect("failed to load paths");
let slaves_by_master = open_or_create_file(&config, "SLAVES_BY_MASTER") // master-slave_relations.toml
.pipe_as_ref(toml::from_str)
.expect("failed to load SLAVES_BY_MASTER");
let slave_properties = open_or_create_file(&config, "SLAVE_PROPERTIES") // slave_properties.toml
.pipe_as_ref(toml::from_str)
.expect("failed to load SLAVE_PROPERTIES");
let machine_ids = open_or_create_file(&config, "MACHINE_IDS") // /root/fabfire/config.toml
.pipe_as_ref(toml::from_str::<toml::Table>)
.expect("failed to load MACHINE_IDS")
["readers"]
.as_table()
.unwrap()
.iter()
.map(|(_key, value)| {
let entry = value.as_table().unwrap();
(
entry["machine"].as_str().unwrap().replace("urn:fabaccess:resource:", ""),
entry["id"].as_str().unwrap().into()
)
})
.collect();
let data_user = open_or_create_file(&config, "DATA_USER") // DataUser.csv
.lines()
.map(|line| {
let mut splits = line.split(',');
let name = splits.next().unwrap().to_string();
let ud = UserData {
id : splits.next().unwrap().parse ().ok(),
to_be_used: splits.next().unwrap().parse::<i32>().unwrap_or(1) == 1,
};
(name, ud)
})
.collect();
let data_machines = open_or_create_file(&config, "DATA_MACHINES") // DataMachines.csv
.lines()
.map(|line| {
let mut splits = line.split(',');
let name = splits.next().unwrap().to_string();
let md = MachineData {
id : splits.next().unwrap().parse ().ok(),
to_be_used : splits.next().unwrap().parse::<i32>().unwrap_or(1) == 1,
power_sense: splits.next().unwrap().parse::<i32>().unwrap() == 1,
divider : splits.next().unwrap().parse ().unwrap()
};
(name, md)
})
.collect();
Self {
slaves_by_master,
slave_properties,
machine_ids,
data_user,
data_machines,
billing_log : config.get("BILLING_LOG").unwrap(),
machine_log : config.get("MACHINE_LOG").unwrap(),
debug_log : config.get("DEBUG_LOG").unwrap(),
mqtt_host : config.get("MQTT_HOST").unwrap(),
mqtt_username: config.get("MQTT_USERNAME").ok(),
mqtt_password: config.get("MQTT_PASSWORD").ok()
}
}
}
fn open_or_create_file(config: &Config, key: &str) -> String {
let path = config
.get_string(key)
.unwrap();
let mut out = String::new();
File::options()
.read(true)
// .create(true)
.open(path)
.unwrap()
.read_to_string(&mut out)
.unwrap();
out
}

View File

@ -6,9 +6,9 @@ use colour::dark_grey_ln;
use rumqttc::{AsyncClient, QoS};
use tokio::sync::RwLock;
use crate::my_config::MyConfig;
use crate::utils::index;
use crate::utils::booking::Booking;
use crate::SLAVE_PROPERTIES;
mod announcer;
mod listener;
@ -20,15 +20,17 @@ pub struct Announcer;
pub struct State<Kind> {
#[expect(dead_code, reason = "like PhantomData")]
pub kind: Kind,
pub config: Arc<MyConfig>,
pub client: Arc<RwLock<AsyncClient>>,
pub bookings: Arc<RwLock<HashMap<String, Booking>>>,
pub scheduled_shutdowns: Arc<RwLock<VecDeque<(Instant, String)>>>
}
impl<Kind> State<Kind> {
pub fn new(kind: Kind, client: AsyncClient) -> Self {
pub fn new(kind: Kind, client: AsyncClient, my_config: MyConfig) -> Self {
Self {
kind,
config: Arc::new(my_config),
client: Arc::new(RwLock::new(client)),
bookings: Default::default(),
scheduled_shutdowns: Default::default()
@ -38,6 +40,7 @@ impl<Kind> State<Kind> {
pub fn duplicate_as<NewKind>(&self, kind: NewKind) -> State<NewKind> {
State {
kind,
config: Arc::clone(&self.config),
client: Arc::clone(&self.client),
bookings: Arc::clone(&self.bookings),
scheduled_shutdowns: Arc::clone(&self.scheduled_shutdowns)
@ -47,7 +50,7 @@ impl<Kind> State<Kind> {
//probably doesn't belong here, dunno where else to put it
async fn set_power_state(&self, machine: &str, new_state: bool) {
dark_grey_ln!("set power state - {machine} {new_state}");
let is_tasmota = SLAVE_PROPERTIES[machine][index::IS_TASMOTA];
let is_tasmota = self.config.slave_properties[machine][index::IS_TASMOTA];
let topic =
if is_tasmota {
format!("cmnd/{machine}/Power")

View File

@ -7,7 +7,6 @@ use rumqttc::QoS;
use tap::Pipe;
use tokio::time::sleep;
use crate::MACHINE_IDS;
use crate::utils::{create_display_time_string, minute_mark};
use crate::{Announcer, State};
@ -35,7 +34,7 @@ impl State<Announcer> {
blue_ln!("updating display of {machine}");
let Some(id) = MACHINE_IDS.get(machine) else {
let Some(id) = self.config.machine_ids.get(machine) else {
red_ln!("error: no ID found for {machine}");
return None;
};

View File

@ -7,7 +7,7 @@ use rumqttc::EventLoop;
use rumqttc::Event::Incoming;
use rumqttc::Packet::Publish;
use crate::{State, Listener, BOOKING_TOPIC, SLAVES_BY_MASTER, SLAVE_PROPERTIES};
use crate::{State, Listener, BOOKING_TOPIC};
use crate::utils::index;
use crate::utils::get_power_state;
use crate::utils::logs::{log_debug, machinelog};
@ -35,12 +35,12 @@ impl State<Listener> {
dark_grey_ln!("payload: {payload}");
let result = self.handle_payload(&publish.topic, &payload).await;
log_debug(&publish.topic, &payload, result)
.expect("debug log failed")
log_debug(&publish.topic, &payload, result, &self.config)
.expect("debug log failed");
}
async fn handle_payload(&mut self, topic: &str, payload: &str) -> Result<(), &str> {
async fn handle_payload(&mut self, topic: &str, payload: &str) -> Result<(), &'static str> {
let splits: Result<[_; 3], _> = topic
.split('/')
.collect::<Vec<_>>()
@ -97,7 +97,7 @@ impl State<Listener> {
.remove(machine)
.ok_or("released unbooked machine")?;
machinelog(machine, &booking)
machinelog(machine, &booking, &self.config)
.expect("machine log failed");
let was_running = booking.track(false);
@ -143,24 +143,27 @@ impl State<Listener> {
.iter()
.filter(|(other, _booking)| *other != master)
.flat_map(|(machine, booking)|
SLAVES_BY_MASTER
self.config
.slaves_by_master
.get(machine)
.unwrap_or(&fallback) // machine being unknown already got logged when it got turned on, so we can ignore it here
.iter()
.filter(|slave| booking.is_running() || SLAVE_PROPERTIES[*slave][index::RUNS_CONTINUOUSLY])
.filter(|slave| booking.is_running() || self.config.slave_properties[*slave][index::RUNS_CONTINUOUSLY])
)
.cloned()
.collect();
let slaves_to_update = SLAVES_BY_MASTER
let slaves_to_update = self
.config
.slaves_by_master
.get(master)
.ok_or("unknown master")?
.sub(&slaves_used_by_others)
.into_iter()
.filter(|slave| if SLAVE_PROPERTIES[slave][index::RUNS_CONTINUOUSLY] { long_slaves } else { short_slaves });
.filter(|slave| if self.config.slave_properties[slave][index::RUNS_CONTINUOUSLY] { long_slaves } else { short_slaves });
for slave in slaves_to_update {
if SLAVE_PROPERTIES[&slave][index::NEEDS_TRAILING_TIME] {
if self.config.slave_properties[&slave][index::NEEDS_TRAILING_TIME] {
if power {
self.cancel_scheduled_shutdown(&slave).await;
} else {

View File

@ -1,28 +1,9 @@
use std::fs;
use std::fs::File;
use std::io::ErrorKind;
use std::time::Duration;
use serde::de::DeserializeOwned;
use tap::Pipe;
pub mod logs;
pub mod booking;
pub mod index;
pub fn parse_toml_file<T: DeserializeOwned>(path: &str) -> T {
match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == ErrorKind::NotFound => {
File::create(path).expect("failed to create file");
String::new()
},
_ => panic!("error reading {path}")
}
.pipe_as_ref(toml::from_str)
.expect("failed to parse toml")
}
pub fn get_power_state(payload: &str) -> Result<String, &'static str> {
//todo: there gotta be an easier way to do this
json::parse(payload)

View File

@ -1,5 +1,5 @@
use std::ops::Div;
use std::io::{self, Write};
use std::{io::Write, ops::Div};
use std::io;
use std::fs::File;
use chrono::Local;
@ -7,7 +7,7 @@ use colour::red_ln;
use csv::WriterBuilder;
use serde::Serialize;
use crate::utils::booking::Booking;
use crate::{my_config::MyConfig, utils::booking::Booking};
use self::billing::billinglog;
@ -24,8 +24,8 @@ struct Record<'s> {
user: &'s str
}
pub fn machinelog(machine: &str, booking: &Booking) -> io::Result<()> {
billinglog(machine, booking)?;
pub fn machinelog(machine: &str, booking: &Booking, config: &MyConfig) -> io::Result<()> {
billinglog(machine, booking, config)?;
let record = Record {
machine,
@ -40,7 +40,7 @@ pub fn machinelog(machine: &str, booking: &Booking) -> io::Result<()> {
let file_writer = File::options()
.create(true)
.append(true)
.open("machinelog.csv")?;
.open(&config.machine_log)?;
WriterBuilder::new()
.has_headers(false)
@ -52,15 +52,7 @@ pub fn machinelog(machine: &str, booking: &Booking) -> io::Result<()> {
})
}
pub fn log_start() -> io::Result<()> {
File::options()
.create(true)
.append(true)
.open("machinelog_debug.csv")?
.write_all(format!("\n\n===== startup {} =====\n\n", Local::now()).as_bytes())
}
pub fn log_debug(topic: &str, payload: &str, result: Result<(), &str>) -> io::Result<()> {
pub fn log_debug(topic: &str, payload: &str, result: Result<(), &str>, config: &MyConfig) -> io::Result<()> {
if let Err(error) = result {
red_ln!("error: {error}");
red_ln!(" topic: {topic}");
@ -80,6 +72,6 @@ result: {result}",
File::options()
.append(true)
.open("machinelog_debug.csv")?
.open(&config.debug_log)?
.write_all(record.as_bytes())
}

View File

@ -1,80 +1,28 @@
use std::{io, ops::Div};
use std::collections::HashMap;
use std::fs;
use std::fs::File;
use chrono::Local;
use colour::red_ln;
use csv::WriterBuilder;
use lazy_static::lazy_static;
use serde::Serialize;
use crate::utils::booking::Booking;
#[derive(Debug, Clone)]
pub struct UserData {
id: Option<i32>,
to_be_used: bool
}
#[derive(Debug, Clone)]
pub struct MachineData {
id: Option<i32>,
to_be_used: bool,
power_sense: bool, //1 = runtime, 0 = booked time
divider: i32
}
lazy_static! {
pub static ref DATA_USER: HashMap<String, UserData> = fs::read_to_string("DataUser.csv")
.expect("failed to open DataUser.csv")
.lines()
.map(|line| {
let mut splits = line.split(',');
let name = splits.next().unwrap().to_string();
let ud = UserData {
id : splits.next().unwrap().parse ().ok(),
to_be_used: splits.next().unwrap().parse::<i32>().unwrap_or(1) == 1,
};
(name, ud)
})
.collect();
pub static ref DATA_MACHINES: HashMap<String, MachineData> = fs::read_to_string("DataMachines.csv")
.expect("failed to open DataMachines.csv")
.lines()
.map(|line| {
let mut splits = line.split(',');
let name = splits.next().unwrap().to_string();
let md = MachineData {
id : splits.next().unwrap().parse ().ok(),
to_be_used : splits.next().unwrap().parse::<i32>().unwrap_or(1) == 1,
power_sense: splits.next().unwrap().parse::<i32>().unwrap() == 1,
divider : splits.next().unwrap().parse ().unwrap()
};
(name, md)
})
.collect();
}
use crate::utils::booking::Booking;
use crate::my_config::{MachineData, MyConfig};
#[derive(Debug, Serialize)]
struct BillingRecord {
user_id: String, // id nachschlagen in DataUser.csv. Wenn Spalte 3 ("toBeUsed") == 0 dann skip. Wenn nicht vorhanden dann fallback zum Namen
quelle: &'static str, // "allgemeiner Beleg"
brutto_netto: i32, // 2
artikel_id: String, // DataMachine.csv#2
artikel_id: String, // DataMachine.csv#2
positionsdetails: String, // Date
anzahl: i32, // minutes divided by DataMachine.csv#5 (ceil)
rechnungstyp: i32 // 0
}
pub fn billinglog(machine: &str, booking: &Booking) -> io::Result<()> {
pub fn billinglog(machine: &str, booking: &Booking, config: &MyConfig) -> io::Result<()> {
let user_id =
if let Some(user_data) = &DATA_USER.get(&booking.user.to_string()) {
if let Some(user_data) = &config.data_user.get(&booking.user.to_string()) {
if !user_data.to_be_used { return Ok(()); }
user_data
.id
@ -84,8 +32,9 @@ pub fn billinglog(machine: &str, booking: &Booking) -> io::Result<()> {
booking.user.to_string()
};
let machine_data = &DATA_MACHINES
.get(&machine.to_string())
let machine_data = &config
.data_machines
.get(machine)
.unwrap_or(&MachineData {
id: None,
to_be_used: true,
@ -127,7 +76,7 @@ pub fn billinglog(machine: &str, booking: &Booking) -> io::Result<()> {
let file_writer = File::options()
.create(true)
.append(true)
.open("billinglog.csv")?;
.open(&config.billing_log)?;
WriterBuilder::new()
.has_headers(false)