Compare commits

..

4 Commits

Author SHA1 Message Date
7a438d1a9f refactored main function
- moved logger into its own file
- moved code from main into its own function
2025-06-01 18:03:16 +02:00
3fe2f3f376 disable hotspot when no activity on webserver 2025-06-01 15:42:13 +02:00
3b3633f6f5 added activity_fairing for webserver 2025-06-01 15:41:24 +02:00
c04e0ab897 changed return type on Hotspot trait 2025-06-01 15:39:59 +02:00
6 changed files with 256 additions and 153 deletions

46
src/activity_fairing.rs Normal file
View File

@@ -0,0 +1,46 @@
use std::time::Duration;
use log::error;
use rocket::{
Data, Request,
fairing::{Fairing, Info, Kind},
};
use tokio::{sync::mpsc, time::timeout};
pub struct ActivityNotifier {
pub sender: mpsc::Sender<()>,
}
#[rocket::async_trait]
impl Fairing for ActivityNotifier {
fn info(&self) -> Info {
Info {
name: "Keeps track of time since the last request",
kind: Kind::Request | Kind::Response,
}
}
async fn on_request(&self, _: &mut Request<'_>, _: &mut Data<'_>) {
error!("on_request");
let _ = self.sender.try_send(());
}
}
pub fn spawn_idle_watcher<F>(idle_duration: Duration, mut on_idle: F) -> mpsc::Sender<()>
where
F: FnMut() + Send + 'static,
{
let (tx, mut rx) = mpsc::channel::<()>(100);
tokio::spawn(async move {
loop {
let idle = timeout(idle_duration, rx.recv()).await;
if idle.is_err() {
// No activity received in the duration
on_idle();
}
}
});
tx
}

70
src/feedback.rs Normal file
View File

@@ -0,0 +1,70 @@
use log::error;
use rppal::pwm::Channel;
use std::error::Error;
use tokio::join;
use crate::{
buzzer::{Buzzer, GPIOBuzzer},
led::{SpiLed, StatusLed},
};
#[cfg(feature = "mock_pi")]
use crate::mock::{MockBuzzer, MockLed};
const PWM_CHANNEL_BUZZER: Channel = Channel::Pwm0; //PWM0 = GPIO18/Physical pin 12
pub struct Feedback<B: Buzzer, L: StatusLed> {
buzzer: B,
led: L,
}
impl<B: Buzzer, L: StatusLed> Feedback<B, L> {
pub async fn success(&mut self) {
let (buzzer_result, led_result) =
join!(self.buzzer.beep_ack(), self.led.turn_green_on_1s());
buzzer_result.unwrap_or_else(|err| {
error!("Failed to buzz: {err}");
});
led_result.unwrap_or_else(|err| {
error!("Failed to set LED: {err}");
});
}
pub async fn failure(&mut self) {
let (buzzer_result, led_result) = join!(self.buzzer.beep_nak(), self.led.turn_red_on_1s());
buzzer_result.unwrap_or_else(|err| {
error!("Failed to buzz: {err}");
});
led_result.unwrap_or_else(|err| {
error!("Failed to set LED: {err}");
});
}
}
#[cfg(feature = "mock_pi")]
pub type FeedbackImpl = Feedback<MockBuzzer, MockLed>;
#[cfg(not(feature = "mock_pi"))]
pub type FeedbackImpl = Feedback<GPIOBuzzer, SpiLed>;
impl FeedbackImpl {
pub fn new() -> Result<Self, Box<dyn Error>> {
#[cfg(feature = "mock_pi")]
{
Ok(Feedback {
buzzer: MockBuzzer {},
led: MockLed {},
})
}
#[cfg(not(feature = "mock_pi"))]
{
Ok(Feedback {
buzzer: GPIOBuzzer::new(PWM_CHANNEL_BUZZER)?,
led: SpiLed::new()?,
})
}
}
}

View File

@@ -46,7 +46,9 @@ pub trait Hotspot {
&self,
) -> impl std::future::Future<Output = Result<(), HotspotError>> + std::marker::Send;
async fn disable_hotspot(&self) -> Result<(), HotspotError>;
fn disable_hotspot(
&self,
) -> impl std::future::Future<Output = Result<(), HotspotError>> + std::marker::Send;
}
/// NetworkManager Hotspot

25
src/logger.rs Normal file
View File

@@ -0,0 +1,25 @@
use std::env;
use log::LevelFilter;
use simplelog::{ConfigBuilder, SimpleLogger};
pub fn setup_logger() {
let log_level = env::var("LOG_LEVEL")
.ok()
.and_then(|level| level.parse::<LevelFilter>().ok())
.unwrap_or({
if cfg!(debug_assertions) {
LevelFilter::Debug
} else {
LevelFilter::Warn
}
});
let config = ConfigBuilder::new()
.set_target_level(LevelFilter::Off)
.set_location_level(LevelFilter::Off)
.set_thread_level(LevelFilter::Off)
.build();
let _ = SimpleLogger::init(log_level, config);
}

View File

@@ -1,28 +1,33 @@
use buzzer::{Buzzer, GPIOBuzzer};
use activity_fairing::{ActivityNotifier, spawn_idle_watcher};
use feedback::{Feedback, FeedbackImpl};
use hotspot::{Hotspot, HotspotError, NMHotspot};
use id_store::IDStore;
use led::{SpiLed, StatusLed};
use log::{LevelFilter, debug, error, info, warn};
use log::{error, info, warn};
use pm3::run_pm3;
use rppal::pwm::Channel;
use simplelog::{ConfigBuilder, SimpleLogger};
use std::{env, error::Error, sync::Arc};
use std::{env, error::Error, sync::Arc, time::Duration};
use tally_id::TallyID;
use tokio::{
fs, join,
sync::{Mutex, broadcast, mpsc},
fs,
sync::{
Mutex,
broadcast::{self, Receiver, Sender},
},
try_join,
};
use webserver::start_webserver;
#[cfg(feature = "mock_pi")]
use mock::{MockBuzzer, MockHotspot, MockLed};
use mock::MockHotspot;
mod activity_fairing;
mod buzzer;
mod color;
mod feedback;
mod hotspot;
mod id_mapping;
mod id_store;
mod led;
mod logger;
mod mock;
mod parser;
mod pm3;
@@ -30,94 +35,6 @@ mod tally_id;
mod webserver;
const STORE_PATH: &str = "./data.json";
const PWM_CHANNEL_BUZZER: Channel = Channel::Pwm0; //PWM0 = GPIO18/Physical pin 12
fn setup_logger() {
let log_level = env::var("LOG_LEVEL")
.ok()
.and_then(|level| level.parse::<LevelFilter>().ok())
.unwrap_or({
if cfg!(debug_assertions) {
LevelFilter::Debug
} else {
LevelFilter::Warn
}
});
let config = ConfigBuilder::new()
.set_target_level(LevelFilter::Off)
.set_location_level(LevelFilter::Off)
.set_thread_level(LevelFilter::Off)
.build();
let _ = SimpleLogger::init(log_level, config);
}
/// Signal the user success via buzzer and led
async fn feedback_success<T: Buzzer, I: StatusLed>(
gpio_buzzer: &Arc<Mutex<T>>,
status_led: &Arc<Mutex<I>>,
) {
let mut buzzer_guard = gpio_buzzer.lock().await;
let mut led_guard = status_led.lock().await;
let (buzz, led) = join!(buzzer_guard.beep_ack(), led_guard.turn_green_on_1s());
buzz.unwrap_or_else(|err| {
error!("Failed to buzz: {err}");
});
led.unwrap_or_else(|err| {
error!("Failed to set LED: {err}");
});
}
/// Signal the user failure via buzzer and led
async fn feedback_failure<T: Buzzer, I: StatusLed>(
gpio_buzzer: &Arc<Mutex<T>>,
status_led: &Arc<Mutex<I>>,
) {
let mut buzzer_guard = gpio_buzzer.lock().await;
let mut led_guard = status_led.lock().await;
let (buzz, led) = join!(buzzer_guard.beep_nak(), led_guard.turn_red_on_1s());
buzz.unwrap_or_else(|err| {
error!("Failed to buzz: {err}");
});
led.unwrap_or_else(|err| {
error!("Failed to set LED: {err}");
});
}
/// Create a buzzer
/// Respects the `mock_pi` flag.
fn create_buzzer() -> Result<Arc<Mutex<impl Buzzer>>, rppal::pwm::Error> {
#[cfg(feature = "mock_pi")]
{
Ok(Arc::new(Mutex::new(MockBuzzer {})))
}
#[cfg(not(feature = "mock_pi"))]
{
Ok(Arc::new(Mutex::new(GPIOBuzzer::new(PWM_CHANNEL_BUZZER)?)))
}
}
/// Creates a status led.
/// Respects the `mock_pi` flag.
fn create_status_led() -> Result<Arc<Mutex<impl StatusLed>>, rppal::spi::Error> {
#[cfg(feature = "mock_pi")]
{
Ok(Arc::new(Mutex::new(MockLed {})))
}
#[cfg(not(feature = "mock_pi"))]
{
Ok(Arc::new(Mutex::new(SpiLed::new()?)))
}
}
/// Create a struct to manage the hotspot
/// Respects the `mock_pi` flag.
@@ -133,41 +50,41 @@ fn create_hotspot() -> Result<impl Hotspot, HotspotError> {
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
setup_logger();
info!("Starting application");
let (tx, mut rx) = broadcast::channel::<String>(32);
let sse_tx = tx.clone();
async fn run_webserver<H>(
store: Arc<Mutex<IDStore>>,
id_channel: Sender<String>,
hotspot: Arc<Mutex<H>>,
) -> Result<(), Box<dyn Error>>
where
H: Hotspot + Send + Sync + 'static,
{
let activity_channel = spawn_idle_watcher(Duration::from_secs(60 * 30), move || {
info!("No activity on webserver. Disabling hotspot");
let cloned_hotspot = hotspot.clone();
tokio::spawn(async move {
match run_pm3(tx).await {
Ok(()) => {
warn!("PM3 exited with a zero exit code");
}
Err(e) => {
error!("Failed to run PM3: {e}");
}
}
let _ = cloned_hotspot.lock().await.disable_hotspot().await;
});
});
let raw_store = if fs::try_exists(STORE_PATH).await? {
info!("Loading data from file");
IDStore::new_from_json(STORE_PATH).await?
} else {
info!("No data file found. Creating empty one.");
IDStore::new()
let notifier = ActivityNotifier {
sender: activity_channel,
};
debug!("created store sucessfully");
start_webserver(store, id_channel, notifier).await?;
Ok(())
}
let store: Arc<Mutex<IDStore>> = Arc::new(Mutex::new(raw_store));
let gpio_buzzer = create_buzzer()?;
let status_led = create_status_led()?;
let hotspot = create_hotspot()?;
async fn load_or_create_store() -> Result<IDStore, Box<dyn Error>> {
if fs::try_exists(STORE_PATH).await? {
info!("Loading data from file");
IDStore::new_from_json(STORE_PATH).await
} else {
info!("No data file found. Creating empty one.");
Ok(IDStore::new())
}
}
fn get_hotspot_enable_ids() -> Vec<TallyID> {
let hotspot_ids: Vec<TallyID> = env::var("HOTSPOT_IDS")
.map(|ids| ids.split(";").map(|id| TallyID(id.to_owned())).collect())
.unwrap_or_default();
@@ -178,39 +95,79 @@ async fn main() -> Result<(), Box<dyn Error>> {
);
}
let channel_store = store.clone();
tokio::spawn(async move {
while let Ok(tally_id_string) = rx.recv().await {
hotspot_ids
}
async fn handle_ids_loop(
mut id_channel: Receiver<String>,
hotspot_enable_ids: Vec<TallyID>,
id_store: Arc<Mutex<IDStore>>,
hotspot: Arc<Mutex<impl Hotspot>>,
mut user_feedback: FeedbackImpl,
) -> Result<(), Box<dyn Error>> {
while let Ok(tally_id_string) = id_channel.recv().await {
let tally_id = TallyID(tally_id_string);
if hotspot_ids.contains(&tally_id) {
if hotspot_enable_ids.contains(&tally_id) {
info!("Enableing hotspot");
hotspot.enable_hotspot().await.unwrap_or_else(|err| {
hotspot
.lock()
.await
.enable_hotspot()
.await
.unwrap_or_else(|err| {
error!("Hotspot: {err}");
});
// TODO: Should the ID be added anyway or ignored ?
}
if channel_store.lock().await.add_id(tally_id) {
if id_store.lock().await.add_id(tally_id) {
info!("Added new id to current day");
feedback_success(&gpio_buzzer, &status_led).await;
user_feedback.success().await;
if let Err(e) = channel_store.lock().await.export_json(STORE_PATH).await {
if let Err(e) = id_store.lock().await.export_json(STORE_PATH).await {
error!("Failed to save id store to file: {e}");
feedback_failure(&gpio_buzzer, &status_led).await;
user_feedback.failure().await;
// TODO: How to handle a failure to save ?
}
}
}
});
match start_webserver(store.clone(), sse_tx).await {
Ok(()) => {}
Err(e) => {
error!("Failed to start webserver: {e}");
}
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
logger::setup_logger();
info!("Starting application");
let (tx, rx) = broadcast::channel::<String>(32);
let sse_tx = tx.clone();
let store: Arc<Mutex<IDStore>> = Arc::new(Mutex::new(load_or_create_store().await?));
let user_feedback = Feedback::new()?;
let hotspot = Arc::new(Mutex::new(create_hotspot()?));
let hotspot_enable_ids = get_hotspot_enable_ids();
let pm3_handle = run_pm3(tx);
let loop_handle = handle_ids_loop(
rx,
hotspot_enable_ids,
store.clone(),
hotspot.clone(),
user_feedback,
);
let webserver_handle = run_webserver(store.clone(), sse_tx, hotspot.clone());
let run_result = try_join!(pm3_handle, loop_handle, webserver_handle);
if let Err(e) = run_result {
error!("Failed to run application: {e}");
}
Ok(())
}

View File

@@ -14,6 +14,7 @@ use tokio::select;
use tokio::sync::Mutex;
use tokio::sync::broadcast::Sender;
use crate::activity_fairing::ActivityNotifier;
use crate::id_mapping::{IDMapping, Name};
use crate::id_store::IDStore;
use crate::tally_id::TallyID;
@@ -31,6 +32,7 @@ struct NewMapping {
pub async fn start_webserver(
store: Arc<Mutex<IDStore>>,
sse_broadcaster: Sender<String>,
fairing: ActivityNotifier,
) -> Result<(), rocket::Error> {
let port = match env::var("HTTP_PORT") {
Ok(port) => port.parse().unwrap_or_else(|_| {
@@ -47,6 +49,7 @@ pub async fn start_webserver(
};
rocket::custom(config)
.attach(fairing)
.mount(
"/",
routes![