diff --git a/.env b/.env index 1287047..8033de3 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ DATABASE_URL=postgres://dev:dev@db/iot -RUST_LOG=debug \ No newline at end of file +RUST_LOG=debug +FIRMWARE_FOLDER=/temp/firmware \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index d5f9aa3..634653f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ edition = "2021" actix-web = "4.4.0" chrono = { version = "0.4.31", features = ["serde"] } dotenvy = "0.15" -enum_stringify = "0.6.1" env_logger = "0.11" log = "0.4.20" serde = "1.0.188" diff --git a/dev/IoT-API/GET firmwares.bru b/dev/IoT-API/GET firmwares.bru index 6123f6d..8331e40 100644 --- a/dev/IoT-API/GET firmwares.bru +++ b/dev/IoT-API/GET firmwares.bru @@ -5,7 +5,7 @@ meta { } get { - url: http://localhost:8484/firmware/waterlevel + url: http://localhost:8282/firmware/waterlevel body: multipartForm auth: none } diff --git a/dev/IoT-API/PUT firmware.bru b/dev/IoT-API/PUT firmware.bru index 0c5fc25..ae8a5a0 100644 --- a/dev/IoT-API/PUT firmware.bru +++ b/dev/IoT-API/PUT firmware.bru @@ -5,11 +5,11 @@ meta { } put { - url: http://localhost:8484/firmware/waterlevel/INA226/0.0.1 + url: http://localhost:8282/firmware/waterlevel/INA233/1.0.0 body: multipartForm auth: none } body:multipart-form { - : @file(/home/tobi/git/iot-cloud-api/target/debug/iot-cloud) + : @file(/home/tobi/git/waterlevel-software/.pio/build/ESP32/firmware.bin) } diff --git a/src/main.rs b/src/main.rs index a1d2968..05eb748 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -use std::{env, fs, process, str::FromStr}; +use std::{env, fs, path::PathBuf, process, str::FromStr}; use crate::schemas::{TelemetryMessageFromDevice, ValueMessageFromDevice}; use actix_web::{get, post, put, web, App, HttpResponse, HttpServer, Responder}; use database::Database; use dotenvy::dotenv; -use log::{error, info}; -use schemas::{BoardConfig, BoardType, Device, OTAConfiguration}; +use log::{debug, error, info}; +use schemas::{BoardConfig, BoardType, Device, OTAConfiguration, OTAConfigurationList}; use sqlx::types::mac_address::MacAddress; use util::parse_mac_address; @@ -127,43 +127,49 @@ async fn get_devices(data: web::Data) -> impl Responder { #[put("/firmware/{product}/{config}/{version}")] async fn upload_firmware(path: web::Path<(String, String, String)>, body: web::Bytes) -> impl Responder { let (product, config, version) = path.into_inner(); - println!("Uploading firmware version: {}", version); + let version = version.replace(".", "_"); + let firmware_root_path = PathBuf::from(env::var("FIMRWARE_FOLDER").unwrap_or("./firmware".to_string())); - let fw_folder = format!("./firmware/{product}"); + let firmware_folder = firmware_root_path.join(&product).join(&config); + let firmware_path = firmware_folder.join(format!("ver_{}", &version)).with_extension("bin"); + + info!("Uploading firmware with product: {product}, config: {config} and version: {version} to {firmware_path:?}"); - fs::create_dir_all(&fw_folder).unwrap(); - let file_path = format!("{fw_folder}/firmware_{config}_{version}.bin"); - info!("Saving to {file_path}"); - tokio::fs::write(&file_path, &body).await.unwrap(); + fs::create_dir_all(&firmware_folder).unwrap(); + let x = tokio::fs::write(&firmware_path, &body).await; + debug!("{x:?}"); HttpResponse::Ok().body(format!("Firmware version {} uploaded successfully", version)) } -// TODO this is more or less a placeholder. Firmware upload will be more detailed in the future -#[get("/firmware/{product}")] -async fn get_firmware_json(product: web::Path) -> impl Responder { - let product = product.into_inner(); - let mut configs = Vec::new(); - if let Ok(entries) = fs::read_dir(format!("./firmware/{product}")) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() { - println!("File: {:?}", path); - let split_name: Vec<_> = path.file_name().unwrap().to_str().unwrap().split("_").collect(); - let version = split_name[2].strip_suffix(".bin").unwrap(); - let board_config = BoardConfig::from_str(split_name[1]).unwrap(); - let board_type = BoardType::from_str(&product).unwrap(); - let cfg = OTAConfiguration{board: board_type, configuration: board_config, version: version.to_string(), url: path.to_str().to_owned().unwrap().to_owned() }; - configs.push(cfg); - } else if path.is_dir() { - println!("Directory: {:?}", path); - } - } - } else { - return HttpResponse::InternalServerError().finish() - } +#[get("/firmware/{product}/{config}/{version}")] +async fn get_firmware_json(path: web::Path<(String, String, String)>) -> impl Responder { + let (product, config, version) = path.into_inner(); + let version = version.replace(".", "_"); - HttpResponse::Ok().json(configs) + + let mut configs = Vec::new(); + + + HttpResponse::Ok().json(OTAConfigurationList{configurations: configs} ) +} + +#[get("/firmware/waterlevel/firmware_INA233_1.0.0.bin")] +async fn serve_firmware() -> impl Responder { + let file_path = PathBuf::from("./firmware/waterlevel/firmware_INA233_1.0.0.bin"); + + if file_path.exists() { + // Serve the file as a download + HttpResponse::Ok() + .content_type("application/octet-stream") // Binary file MIME type + .insert_header(( + "Content-Disposition", + format!("attachment; filename=\"{}\"", "firmware_INA233_1.0.0.bin"), + )) + .body(std::fs::read(file_path).unwrap_or_else(|_| Vec::new())) + } else { + HttpResponse::NotFound().body("Firmware version not found") + } } #[actix_web::main] @@ -195,8 +201,9 @@ async fn main() -> std::io::Result<()> { .service(get_devices) .service(upload_firmware) .service(get_firmware_json) + .service(serve_firmware) }) - .bind(("0.0.0.0", 8484))? + .bind(("0.0.0.0", 8282))? .run() .await } diff --git a/src/schemas.rs b/src/schemas.rs index 3e2c72b..ea7229a 100644 --- a/src/schemas.rs +++ b/src/schemas.rs @@ -1,8 +1,7 @@ use chrono::NaiveDateTime; -use enum_stringify::EnumStringify; use serde::{ser::SerializeStruct, Deserialize, Serialize}; use sqlx::types::mac_address::MacAddress; -use strum::EnumString; +use strum::{Display, EnumString}; #[derive(Deserialize, Debug, Serialize)] pub struct TelemetryMessage { @@ -65,21 +64,29 @@ impl Serialize for Device { } #[derive(serde::Serialize)] -pub struct OTAConfiguration { - pub board: BoardType, - pub configuration: BoardConfig, - pub version: String, - pub url: String - +#[serde(rename_all = "PascalCase")] +pub struct OTAConfigurationList { + pub configurations: Vec } -#[derive(serde::Serialize, EnumString)] +#[derive(serde::Serialize, PartialEq, Debug)] +#[serde(rename_all = "PascalCase")] +pub struct OTAConfiguration { + pub version: String, + pub url: String, + pub board: Option, + pub config: Option +} + + +#[derive(serde::Serialize, EnumString, PartialEq, Debug, Display)] +#[strum(serialize_all = "snake_case")] pub enum BoardType { Waterlevel } -#[derive(serde::Serialize, EnumString)] +#[derive(serde::Serialize, EnumString, PartialEq, Debug, Display)] pub enum BoardConfig { INA226, INA233 -} \ No newline at end of file +} diff --git a/src/util.rs b/src/util.rs index a1cbc56..10b594a 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,15 @@ +use std::{fs, path::PathBuf}; + +use log::info; +use strum::EnumString; use thiserror::Error; +use serde; +use std::str::FromStr; + + +use crate::schemas::{BoardConfig, BoardType, OTAConfiguration}; + + pub fn parse_mac_address(mac: &str) -> Result<[u8; 6], MacAddressError> { if mac.len() != 12 { @@ -22,10 +33,62 @@ pub enum MacAddressError { Length(usize), } +#[derive(Error, Debug)] +pub enum GetFilesError { + #[error("IO Error while reading files")] + IO(#[from] std::io::Error), + #[error("Error getting filename")] + Filename, + #[error("Error getting extension (.bin)")] + Extension, + #[error("Strum parse Error")] + Conversion(#[from] strum::ParseError) +} + +pub fn get_files(root_path: &PathBuf, hostname: &str) -> Result, GetFilesError>{ + info!("Getting all files from path {root_path:?}"); + let mut configs = Vec::new(); + let product_name = root_path.file_name().ok_or(GetFilesError::Filename)?.to_string_lossy(); + let entries = fs::read_dir(root_path)?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + println!("File: {:?}", path); + // Splits the filename at the underscores. This is safe to do as names get sanitized on upload and are only uploaded by the pipeline + let split_name: Vec<_> = path.file_name().ok_or(GetFilesError::Filename)?.to_str().ok_or(GetFilesError::Filename)?.split("_").collect(); + let version = split_name[2].strip_suffix(".bin").ok_or(GetFilesError::Extension)?; + let board_config = BoardConfig::from_str(split_name[1])?; + let board_type = BoardType::from_str(&product_name).unwrap(); + let version_replaced = version.replace(".", "_"); + let fw_url = format!("{hostname}/firmware/{board_type}/{board_config}/{version_replaced}.bin"); + let cfg = OTAConfiguration{version: version.to_string(), url: fw_url, board: Some(board_type), config: Some(board_config) }; + configs.push(cfg); + } else if path.is_dir() { + println!("Directory: {:?}", path); + } + } + + Ok(configs) + +} + + + #[cfg(test)] mod tests { use super::*; + #[test] + fn test_file_loading() { + let expected_1 = OTAConfiguration{ version: "1.2.3".to_string(), url: "example.com/firmware/waterlevel/INA233/1_2_3.bin".to_string(), board: Some(BoardType::Waterlevel), config: Some(BoardConfig::INA233) }; + let expected_2 = OTAConfiguration{ version: "4.5.6".to_string(), url: "example.com/firmware/waterlevel/INA226/4_5_6.bin".to_string(), board: Some(BoardType::Waterlevel), config: Some(BoardConfig::INA226) }; + let loaded_configs = get_files(&PathBuf::from("./test/waterlevel"), "example.com").unwrap(); + + assert_eq!(loaded_configs[0], expected_1); + assert_eq!(loaded_configs[1], expected_2); + } + #[test] fn test_valid_mac_address_plain() { let mac_str = "001A2B3C4D5E";