diff --git a/.gitignore b/.gitignore index 6cb0886..21946af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.idea/ /**/.DS_Store +/tmp/ diff --git a/smart-house-web/Cargo.toml b/smart-house-web/Cargo.toml index 72360fd..dfc365c 100644 --- a/smart-house-web/Cargo.toml +++ b/smart-house-web/Cargo.toml @@ -1,6 +1,15 @@ -[package] -name = "smart-house-web" -version = "0.1.0" -edition = "2024" +[workspace] +resolver = "3" +members = ["backend"] -[dependencies] +[profile.release] +opt-level = "z" +strip = "symbols" +lto = "fat" +panic = "abort" +codegen-units = 1 +overflow-checks = false +debug-assertions = false +incremental = false + +[workspace.dependencies] diff --git a/smart-house-web/README.md b/smart-house-web/README.md index d6c8380..30ee50f 100644 --- a/smart-house-web/README.md +++ b/smart-house-web/README.md @@ -16,10 +16,10 @@ API backend сервиса предоставляет доступ ко всему базовому функционалу библиотеки умного дома: -- [ ] Добавление/удаление/перечисление комнат в доме и получение информации о конкретной комнате. -- [ ] Добавление/удаление/перечисление устройств в комнате и получение информации о конкретном устройстве. -- [ ] Получение отчёта о доме. -- [ ] Присутствуют функциональные тесты, которые общаются с backend-ом и проверяют его ответы. +- [x] Добавление/удаление/перечисление комнат в доме и получение информации о конкретной комнате. +- [x] Добавление/удаление/перечисление устройств в комнате и получение информации о конкретном устройстве. +- [x] Получение отчёта о доме. +- [x] Присутствуют функциональные тесты, которые общаются с backend-ом и проверяют его ответы. Frontend приложение: diff --git a/smart-house-web/backend/Cargo.toml b/smart-house-web/backend/Cargo.toml new file mode 100644 index 0000000..666690e --- /dev/null +++ b/smart-house-web/backend/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +tracing = "0.1" +tracing-subscriber = "0.3" +tokio = { version = "1.52", features = ["rt", "rt-multi-thread", "signal", "time"] } +axum = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +reqwest = { version = "0.13.3", features = ["json"] } diff --git a/smart-house-web/backend/src/house.rs b/smart-house-web/backend/src/house.rs new file mode 100644 index 0000000..eb906de --- /dev/null +++ b/smart-house-web/backend/src/house.rs @@ -0,0 +1,126 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PowerSocket { + power_rate: f32, + on: bool, +} + +impl PowerSocket { + pub fn new(power_rate: f32, on: bool) -> Self { + Self { power_rate, on } + } + + pub fn is_on(&self) -> bool { + self.on + } + + pub fn set_on(&mut self, on: bool) { + self.on = on + } + + pub fn get_power(&self) -> f32 { + if self.is_on() { self.power_rate } else { 0.0 } + } + + pub fn report(&self) -> String { + let state = if self.is_on() { "ON" } else { "OFF" }; + let power = self.get_power(); + format!("PowerSocket[ {} : {:02.1} ]", state, power) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Thermometer { + temperature: f32, +} + +impl Thermometer { + pub fn new(temperature: f32) -> Self { + Self { temperature } + } + + pub fn get_temperature(&self) -> f32 { + self.temperature + } + + pub fn report(&self) -> String { + let temperature = self.get_temperature(); + format!("Thermometer[ {:02.1} ]", temperature) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Device { + PowerSocket(PowerSocket), + Thermometer(Thermometer), +} + +impl Device { + pub fn report(&self) -> String { + match self { + Device::PowerSocket(v) => v.report().to_string(), + Device::Thermometer(v) => v.report().to_string(), + } + } +} + +impl From for Device { + fn from(value: PowerSocket) -> Self { + Device::PowerSocket(value) + } +} + +impl From for Device { + fn from(value: Thermometer) -> Self { + Device::Thermometer(value) + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Room { + devices: HashMap, +} + +impl Room { + pub fn new(devices: HashMap) -> Self { + Self { devices } + } + + pub fn get_devices(&self) -> &HashMap { + &self.devices + } + + pub fn get_devices_mut(&mut self) -> &mut HashMap { + &mut self.devices + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct House { + rooms: HashMap, +} + +impl House { + pub fn new(rooms: HashMap) -> Self { + Self { rooms } + } + + pub fn get_rooms(&self) -> &HashMap { + &self.rooms + } + + pub fn get_rooms_mut(&mut self) -> &mut HashMap { + &mut self.rooms + } + + pub fn add_room(&mut self, name: String, room: Room) { + self.rooms.insert(name, room); + } + + pub fn del_room(&mut self, name: &str) { + self.rooms.remove(name); + } +} diff --git a/smart-house-web/backend/src/lib.rs b/smart-house-web/backend/src/lib.rs new file mode 100644 index 0000000..9bd7010 --- /dev/null +++ b/smart-house-web/backend/src/lib.rs @@ -0,0 +1,73 @@ +use std::process::exit; +use tracing::{Level, error, trace}; + +/// Ошибка инициализации логгера +const CODE_LOGGER_INITIALIZATION_ERROR: i32 = 1; + +/// Ошибка инициализации рантайма Tokio +const CODE_TOKIO_RUNTIME_CREATION_ERROR: i32 = 2; + +/// Ошибка привязки слушателя +const CODE_LISTENER_BINDING_ERROR: i32 = 3; + +/// Ошибка запуска сервера +const CODE_STARTIG_SERVER_ERROR: i32 = 4; + +/// Ошибка установки обработчика сигнала завершения +const CODE_CTRL_C_SIGNAL_INSTALL_ERROR: i32 = 5; + +/// Инициализация логирования +pub fn init_logger() { + use tracing_subscriber::{ + Layer, filter::Targets, fmt::layer, layer::SubscriberExt, registry, util::SubscriberInitExt, + }; + + let layer = layer() + .compact() + .with_thread_names(true) + .with_file(false) + .with_line_number(false) + .with_filter( + Targets::new() + .with_target("axum::serve", Level::INFO) + .with_target("hyper_util::client::legacy", Level::INFO) + .with_target("reqwest::retry", Level::INFO) + .with_default(Level::TRACE), + ) + .boxed(); + if let Err(e) = registry().with(vec![layer]).try_init() { + eprintln!("Logger initialization failed: {:?}", e); + exit(CODE_LOGGER_INITIALIZATION_ERROR); + } else { + trace!("Logger succesfully initialized"); + } +} + +pub fn build_runtime() -> tokio::runtime::Runtime { + use std::sync::atomic::{AtomicUsize, Ordering}; + + match tokio::runtime::Builder::new_multi_thread() + .name("tokio") + .thread_name_fn(|| { + static LAST_ID: AtomicUsize = AtomicUsize::new(0); + let id = LAST_ID.fetch_add(1, Ordering::SeqCst); + format!("tkwr-{id}") + }) + .worker_threads(2) + .thread_stack_size(256 * 1024) + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(e) => { + error!("Failed to create Tokio runtime: {:?}", e); + exit(crate::CODE_TOKIO_RUNTIME_CREATION_ERROR); + } + } +} + +mod server; +pub use server::server_main; + +mod house; +pub use house::{Device, House, PowerSocket, Room, Thermometer}; diff --git a/smart-house-web/backend/src/main.rs b/smart-house-web/backend/src/main.rs new file mode 100644 index 0000000..ce2b9a4 --- /dev/null +++ b/smart-house-web/backend/src/main.rs @@ -0,0 +1,7 @@ +use backend::{build_runtime, init_logger, server_main}; + +fn main() { + init_logger(); + let runtime = build_runtime(); + runtime.block_on(server_main()); +} diff --git a/smart-house-web/backend/src/server.rs b/smart-house-web/backend/src/server.rs new file mode 100644 index 0000000..cb31124 --- /dev/null +++ b/smart-house-web/backend/src/server.rs @@ -0,0 +1,82 @@ +use axum::routing::{delete, get, post, put}; +use std::{process::exit, sync::Arc}; +use tokio::sync::RwLock; +use tracing::{error, info}; + +use crate::House; + +/// Тип состояния +type ServerState = Arc>; + +/// Основная функция сервера +pub async fn server_main() { + let state: ServerState = Arc::new(RwLock::new(House::default())); + + let app = axum::Router::new() + // Тестовый эндпоинт для экспериментов + .route("/debug", get(debug::debug)) + // API дома + .route("/rooms", get(house::get_rooms)) + .route("/rooms", post(house::post_rooms)) + // API комнат + .route("/room/{name}", get(room::get_room)) + .route("/room/{name}", put(room::put_room)) + .route("/room/{name}", delete(room::delete_room)) + .route("/room/{name}/devices", get(room::get_devices)) + // API устройств + .route("/room/{name}/device/{name}", get(device::get_device)) + .route("/room/{name}/device/{name}", put(device::put_device)) + .route("/room/{name}/device/{name}", delete(device::delete_device)) + // Состояние и роут по-умолчанию + .with_state(state) + .fallback(fallback); + let addr = "127.0.0.1:8080"; + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => listener, + Err(e) => { + error!("Failed to bind listener to {}: {:?}", addr, e); + exit(crate::CODE_LISTENER_BINDING_ERROR); + } + }; + info!("Starting server at {}...", addr); + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + { + error!("Failed to start server: {:?}", e); + exit(crate::CODE_STARTIG_SERVER_ERROR); + }; + info!("Shutdown server"); +} + +/// Эндпоинт по-умолчанию +async fn fallback() -> axum::response::Response { + use axum::response::IntoResponse; + (axum::http::StatusCode::NOT_FOUND, "404 NOT FOUND").into_response() +} + +/// Аккуратное завершение работы сервера +async fn shutdown_signal() { + // let timeout = async { + // tokio::time::sleep(std::time::Duration::from_secs(10)).await; + // info!("10 seconds timeout expired"); + // }; + let ctrl_c = async { + tokio::signal::ctrl_c().await.map_err(|e| { + error!("Can't install Ctrl+C signal handler: {:?}", e); + exit(crate::CODE_CTRL_C_SIGNAL_INSTALL_ERROR); + }); + info!("Ctrl+C pressed"); + }; + let pending = std::future::pending::<()>(); + tokio::select! { + // _ = timeout => {}, + _ = ctrl_c => {}, + _ = pending => {}, + } +} + +mod debug; +mod device; +mod house; +mod room; diff --git a/smart-house-web/backend/src/server/debug.rs b/smart-house-web/backend/src/server/debug.rs new file mode 100644 index 0000000..a770f42 --- /dev/null +++ b/smart-house-web/backend/src/server/debug.rs @@ -0,0 +1,13 @@ +use std::collections::HashMap; + +use axum::{Json, extract::State}; + +use crate::{Device, PowerSocket, Room, Thermometer}; + +pub async fn debug(State(_server_state): State) -> Json { + let map = HashMap::::from([ + ("thermo".into(), Thermometer::new(20.0).into()), + ("psock".into(), PowerSocket::new(10.0, false).into()), + ]); + Room::new(map).into() +} diff --git a/smart-house-web/backend/src/server/device.rs b/smart-house-web/backend/src/server/device.rs new file mode 100644 index 0000000..1cdd4ea --- /dev/null +++ b/smart-house-web/backend/src/server/device.rs @@ -0,0 +1,44 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; + +use crate::{Device, Room}; + +pub async fn get_device( + State(server_state): State, + Path((room, device)): Path<(String, String)>, +) -> Result, StatusCode> { + let house = server_state.read().await; + let Some(room) = house.get_rooms().get(&room) else { + return Err(StatusCode::NOT_FOUND); + }; + let Some(device) = room.get_devices().get(&device) else { + return Err(StatusCode::NOT_FOUND); + }; + Ok(device.clone().into()) +} + +pub async fn put_device( + State(server_state): State, + Path((room, name)): Path<(String, String)>, + Json(device): Json, +) -> StatusCode { + let mut house = server_state.write().await; + let room = house.get_rooms_mut().entry(room).or_insert(Room::default()); + room.get_devices_mut().insert(name, device); + StatusCode::CREATED +} + +pub async fn delete_device( + State(server_state): State, + Path((room, device)): Path<(String, String)>, +) -> StatusCode { + let mut house = server_state.write().await; + let Some(room) = house.get_rooms_mut().get_mut(&room) else { + return StatusCode::ACCEPTED; + }; + room.get_devices_mut().remove(&device); + StatusCode::ACCEPTED +} diff --git a/smart-house-web/backend/src/server/house.rs b/smart-house-web/backend/src/server/house.rs new file mode 100644 index 0000000..9013fbd --- /dev/null +++ b/smart-house-web/backend/src/server/house.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; + +use axum::{Json, extract::State, http::StatusCode}; + +use crate::Room; + +pub async fn get_rooms( + State(server_state): State, +) -> Json> { + server_state.read().await.get_rooms().clone().into() +} + +pub async fn post_rooms( + State(server_state): State, + Json(map): Json>, +) -> StatusCode { + for (name, room) in map.into_iter() { + server_state.write().await.add_room(name, room); + } + StatusCode::CREATED +} diff --git a/smart-house-web/backend/src/server/room.rs b/smart-house-web/backend/src/server/room.rs new file mode 100644 index 0000000..4643a46 --- /dev/null +++ b/smart-house-web/backend/src/server/room.rs @@ -0,0 +1,49 @@ +use std::collections::HashMap; + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; + +use crate::{Device, Room}; + +pub async fn get_room( + State(server_state): State, + Path(name): Path, +) -> Result, StatusCode> { + let house = server_state.read().await; + let Some(room) = house.get_rooms().get(&name) else { + return Err(StatusCode::NOT_FOUND); + }; + Ok(room.clone().into()) +} + +pub async fn put_room( + State(server_state): State, + Path(name): Path, + Json(room): Json, +) -> StatusCode { + let mut house = server_state.write().await; + house.add_room(name, room); + StatusCode::CREATED +} + +pub async fn delete_room( + State(server_state): State, + Path(name): Path, +) -> StatusCode { + server_state.write().await.del_room(&name); + StatusCode::ACCEPTED +} + +pub async fn get_devices( + State(server_state): State, + Path(name): Path, +) -> Result>, StatusCode> { + let house = server_state.read().await; + let Some(room) = house.get_rooms().get(&name) else { + return Err(StatusCode::NOT_FOUND); + }; + Ok(room.get_devices().clone().into()) +} diff --git a/smart-house-web/backend/test.http b/smart-house-web/backend/test.http new file mode 100644 index 0000000..198a165 --- /dev/null +++ b/smart-house-web/backend/test.http @@ -0,0 +1,71 @@ +### DEBUG +GET http://localhost:8080/debug + +### list rooms +GET http://localhost:8080/rooms + +### post all rooms +POST http://localhost:8080/rooms +Content-Type: application/json + +{ + "ROOM0": { + "devices": {} + }, + "ROOM1": { + "devices": { + "therm": { + "type": "Thermometer", + "temperature": 22 + }, + "psock": { + "type": "PowerSocket", + "power_rate": 11, + "on": false + } + } + } +} + +### drop room +DELETE http://localhost:8080/room/ROOM + +### add room +PUT http://localhost:8080/room/ROOM +Content-Type: application/json + +{ + "devices": { + "therm": { + "type": "Thermometer", + "temperature": 20 + }, + "psock": { + "type": "PowerSocket", + "power_rate": 10, + "on": true + } + } +} + +### get room +GET http://localhost:8080/room/ROOM1 + +### get room devices +GET http://localhost:8080/room/ROOM1/devices + +### get room device +GET http://localhost:8080/room/ROOM/device/TEST + +### get room device +PUT http://localhost:8080/room/ROOM/device/TEST +Content-Type: application/json + +{ + "type": "PowerSocket", + "power_rate": 5, + "on": true +} + +### get room device +DELETE http://localhost:8080/room/ROOM/device/TEST diff --git a/smart-house-web/backend/tests/api_smoke_test.rs b/smart-house-web/backend/tests/api_smoke_test.rs new file mode 100644 index 0000000..c71801b --- /dev/null +++ b/smart-house-web/backend/tests/api_smoke_test.rs @@ -0,0 +1,238 @@ +use std::collections::HashMap; + +use backend::{Device, Room, init_logger, server_main}; +use reqwest::{Client, Response, StatusCode}; +use serde_json::json; +use tokio::spawn; +use tracing::info; + +type RqResult = Result; + +#[tokio::test(flavor = "current_thread")] +async fn smoke() -> RqResult<()> { + init_logger(); + spawn(server_main()); + + let client = Client::new(); + + verify_rooms_state0(&client).await?; + init_rooms(&client).await?; + verify_rooms_state1(&client).await?; + delete_room0(&client).await?; + verify_rooms_state2(&client).await?; + put_room(&client).await?; + verify_rooms_state3(&client).await?; + verify_room1_state0(&client).await?; + verify_room1_devices_state0(&client).await?; + verify_room1_device_psock_state0(&client).await?; + delete_psock(&client).await?; + verify_room1_device_psock_state1(&client).await?; + put_device(&client).await?; + verify_new_device_in_place(&client).await?; + print_house_report(&client).await?; + + Ok(()) +} + +async fn print_resp(resp: Response) -> RqResult<()> { + info!( + "\n<<< StatusCode: {}\n<<< BODY: {}", + resp.status(), + resp.text().await? + ); + Ok(()) +} + +async fn verify_rooms_state0(client: &Client) -> RqResult<()> { + let resp = client.get("http://localhost:8080/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + let rooms = resp.json::>().await?; + assert!(rooms.is_empty()); + + Ok(()) +} + +async fn init_rooms(client: &Client) -> RqResult<()> { + let resp = client + .post("http://localhost:8080/rooms") + .json(&json!({ + "ROOM0": { "devices": {} }, + "ROOM1": { "devices": { + "therm": { "type": "Thermometer", "temperature": 22 }, + "psock": { "type": "PowerSocket", "power_rate": 11, "on": false } + } } + })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + + Ok(()) +} + +async fn verify_rooms_state1(client: &Client) -> RqResult<()> { + let resp = client.get("http://localhost:8080/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + let rooms = resp.json::>().await?; + assert_eq!(rooms.len(), 2); + assert_eq!(rooms.get("ROOM1").unwrap().get_devices().len(), 2); + + Ok(()) +} + +async fn delete_room0(client: &Client) -> RqResult<()> { + let resp = client + .delete("http://localhost:8080/room/ROOM0") + .send() + .await?; + assert_eq!(resp.status(), StatusCode::ACCEPTED); + + Ok(()) +} + +async fn verify_rooms_state2(client: &Client) -> RqResult<()> { + let resp = client.get("http://localhost:8080/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + let rooms = resp.json::>().await?; + assert_eq!(rooms.len(), 1); + assert_eq!(rooms.get("ROOM1").unwrap().get_devices().len(), 2); + + Ok(()) +} + +async fn put_room(client: &Client) -> RqResult<()> { + let resp = client + .put("http://localhost:8080/room/ROOM") + .json(&json!({ "devices": { + "therm": { "type": "Thermometer", "temperature": 20 }, + "psock": { "type": "PowerSocket", "power_rate": 10, "on": true } + } })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + + Ok(()) +} + +async fn verify_rooms_state3(client: &Client) -> RqResult<()> { + let resp = client.get("http://localhost:8080/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + let rooms = resp.json::>().await?; + assert_eq!(rooms.len(), 2); + assert_eq!(rooms.get("ROOM").unwrap().get_devices().len(), 2); + + Ok(()) +} + +async fn verify_room1_state0(client: &Client) -> RqResult<()> { + let resp = client + .get("http://localhost:8080/room/ROOM1") + .send() + .await?; + assert_eq!(resp.status(), StatusCode::OK); + + let room = resp.json::().await?; + assert_eq!(room.get_devices().len(), 2); + + Ok(()) +} + +async fn verify_room1_devices_state0(client: &Client) -> RqResult<()> { + let resp = client + .get("http://localhost:8080/room/ROOM1/devices") + .send() + .await?; + assert_eq!(resp.status(), StatusCode::OK); + + let device_map = resp.json::>().await?; + assert_eq!(device_map.len(), 2); + + Ok(()) +} + +async fn verify_room1_device_psock_state0(client: &Client) -> RqResult<()> { + let resp = client + .get("http://localhost:8080/room/ROOM1/device/psock") + .send() + .await?; + assert_eq!(resp.status(), StatusCode::OK); + + let device = resp.json::().await?; + + let Device::PowerSocket(power_socket) = device else { + panic!("PowerSocket expected"); + }; + assert_eq!(power_socket.get_power(), 0.0); + assert_eq!(power_socket.is_on(), false); + + Ok(()) +} + +async fn delete_psock(client: &Client) -> RqResult<()> { + let resp = client + .delete("http://localhost:8080/room/ROOM1/device/psock") + .send() + .await?; + assert_eq!(resp.status(), StatusCode::ACCEPTED); + + Ok(()) +} + +async fn verify_room1_device_psock_state1(client: &Client) -> RqResult<()> { + let resp = client + .get("http://localhost:8080/room/ROOM1/device/psock") + .send() + .await?; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + Ok(()) +} + +async fn put_device(client: &Client) -> RqResult<()> { + let resp = client + .put("http://localhost:8080/room/ROOM1/device/TEST") + .json(&json!({ + "type": "PowerSocket", + "power_rate": 5, + "on": true + })) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + + Ok(()) +} + +async fn verify_new_device_in_place(client: &Client) -> RqResult<()> { + let resp = client.get("http://localhost:8080/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + let rooms = resp.json::>().await?; + assert_eq!(rooms.len(), 2); + assert_eq!(rooms.get("ROOM1").unwrap().get_devices().len(), 2); + let device = rooms + .get("ROOM1") + .unwrap() + .get_devices() + .get("TEST") + .unwrap(); + let Device::PowerSocket(power_socket) = device else { + panic!("TEST device must be a PowerSocket") + }; + assert!(power_socket.is_on()); + assert_eq!(power_socket.get_power(), 5.0); + + Ok(()) +} + +async fn print_house_report(client: &Client) -> RqResult<()> { + let resp = client.get("http://localhost:8080/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + print_resp(resp).await?; + + Ok(()) +} diff --git a/smart-house-web/src/main.rs b/smart-house-web/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/smart-house-web/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -}