From fc5950c550cba6df4836321eb575af120ea4cfd3 Mon Sep 17 00:00:00 2001 From: Alexander Baranov Date: Mon, 25 May 2026 14:07:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=97=20smart-house-web=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + smart-house-web/.gitignore | 2 + smart-house-web/Cargo.toml | 18 ++ smart-house-web/README.md | 48 ++++ smart-house-web/backend/Cargo.toml | 18 ++ smart-house-web/backend/src/lib.rs | 70 +++++ smart-house-web/backend/src/main.rs | 8 + smart-house-web/backend/src/server.rs | 94 +++++++ smart-house-web/backend/src/server/debug.rs | 13 + smart-house-web/backend/src/server/device.rs | 44 ++++ smart-house-web/backend/src/server/house.rs | 21 ++ smart-house-web/backend/src/server/room.rs | 49 ++++ smart-house-web/backend/test.http | 71 ++++++ .../backend/tests/api_smoke_test.rs | 239 ++++++++++++++++++ smart-house-web/frontend/.gitignore | 1 + smart-house-web/frontend/Cargo.toml | 13 + smart-house-web/frontend/README.md | 10 + smart-house-web/frontend/Trunk.toml | 3 + .../frontend/examples/3_10_children.rs | 92 +++++++ .../frontend/examples/3_4_iteration.rs | 158 ++++++++++++ .../frontend/examples/3_6_form_and_inputs.rs | 99 ++++++++ smart-house-web/frontend/index.html | 5 + smart-house-web/frontend/src/device.rs | 60 +++++ smart-house-web/frontend/src/house.rs | 168 ++++++++++++ smart-house-web/frontend/src/main.rs | 26 ++ smart-house-web/frontend/src/room.rs | 207 +++++++++++++++ smart-house-web/smart_house/Cargo.toml | 7 + smart-house-web/smart_house/src/lib.rs | 126 +++++++++ 28 files changed, 1671 insertions(+) create mode 100644 smart-house-web/.gitignore create mode 100644 smart-house-web/Cargo.toml create mode 100644 smart-house-web/README.md create mode 100644 smart-house-web/backend/Cargo.toml create mode 100644 smart-house-web/backend/src/lib.rs create mode 100644 smart-house-web/backend/src/main.rs create mode 100644 smart-house-web/backend/src/server.rs create mode 100644 smart-house-web/backend/src/server/debug.rs create mode 100644 smart-house-web/backend/src/server/device.rs create mode 100644 smart-house-web/backend/src/server/house.rs create mode 100644 smart-house-web/backend/src/server/room.rs create mode 100644 smart-house-web/backend/test.http create mode 100644 smart-house-web/backend/tests/api_smoke_test.rs create mode 100644 smart-house-web/frontend/.gitignore create mode 100644 smart-house-web/frontend/Cargo.toml create mode 100644 smart-house-web/frontend/README.md create mode 100644 smart-house-web/frontend/Trunk.toml create mode 100644 smart-house-web/frontend/examples/3_10_children.rs create mode 100644 smart-house-web/frontend/examples/3_4_iteration.rs create mode 100644 smart-house-web/frontend/examples/3_6_form_and_inputs.rs create mode 100644 smart-house-web/frontend/index.html create mode 100644 smart-house-web/frontend/src/device.rs create mode 100644 smart-house-web/frontend/src/house.rs create mode 100644 smart-house-web/frontend/src/main.rs create mode 100644 smart-house-web/frontend/src/room.rs create mode 100644 smart-house-web/smart_house/Cargo.toml create mode 100644 smart-house-web/smart_house/src/lib.rs 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/.gitignore b/smart-house-web/.gitignore new file mode 100644 index 0000000..e9e2199 --- /dev/null +++ b/smart-house-web/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/smart-house-web/Cargo.toml b/smart-house-web/Cargo.toml new file mode 100644 index 0000000..c15e8fe --- /dev/null +++ b/smart-house-web/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +members = [ + "backend", + "frontend", "smart_house", +] +resolver = "3" + +[workspace.dependencies] + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +debug-assertions = false +panic = "abort" +overflow-checks = false +incremental = false +strip = "symbols" diff --git a/smart-house-web/README.md b/smart-house-web/README.md new file mode 100644 index 0000000..8b62e8c --- /dev/null +++ b/smart-house-web/README.md @@ -0,0 +1,48 @@ +# ДЗ 2026-04-28 - Веб-сервис умного дома + +## Цель: + +Превращаем умный дом в веб-сервис. + +## Срок: + +Сдать до: **2026-05-25** + +## Описание/Пошаговая инструкция выполнения домашнего задания: + +Реализовать backend сервис для управления умным домом и frontend приложение для взаимодействия с ним. + +- Технология взаимодействия с backend сервисом (gRPC, REST, GraphQL, ...) выбирается произвольно. + +API backend сервиса предоставляет доступ ко всему базовому функционалу библиотеки умного дома: + +- [x] Добавление/удаление/перечисление комнат в доме и получение информации о конкретной комнате. +- [x] Добавление/удаление/перечисление устройств в комнате и получение информации о конкретном устройстве. +- [x] Получение отчёта о доме. +- [x] Присутствуют функциональные тесты, которые общаются с backend-ом и проверяют его ответы. + +Frontend приложение: + +- [x] Отображает список комнат в доме. +- [x] Позволяет перейти к конкретной комнате или добавить новую комнату. +- [x] Отображает список устройств в комнате. +- [x] Позволяет перейти к конкретному устройству или добавить новое устройство. +- [x] Позволяет запросить отчёт о состоянии дома. + +**Критерии оценки:** + +- Workspace успешно собирается. +- Приложения-примеры успешно выполняются. +- Команды cargo clippy, и cargo fmt --check не выводят ошибок и предупреждений. + +## Демо + +Проект разбит на бэкенд и фронтенд. Для демонстрации сначала нужно запустить сервер из подкаталога `baсkend`: + + cargo run + +Затем, не останавливая сервер, запустить фронт из подкаталога `frontend`: + + trunk serve + +Далее можно переходить в браузер по адресу `http://localhost:8080`. diff --git a/smart-house-web/backend/Cargo.toml b/smart-house-web/backend/Cargo.toml new file mode 100644 index 0000000..9612173 --- /dev/null +++ b/smart-house-web/backend/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +smart_house = { path = "../smart_house" } + +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" +tower-http = { version = "0.6", features = ["cors"] } + +[dev-dependencies] +reqwest = { version = "0.13", features = ["json"] } diff --git a/smart-house-web/backend/src/lib.rs b/smart-house-web/backend/src/lib.rs new file mode 100644 index 0000000..e5673f3 --- /dev/null +++ b/smart-house-web/backend/src/lib.rs @@ -0,0 +1,70 @@ +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::{axum_app, server_main}; diff --git a/smart-house-web/backend/src/main.rs b/smart-house-web/backend/src/main.rs new file mode 100644 index 0000000..fda7766 --- /dev/null +++ b/smart-house-web/backend/src/main.rs @@ -0,0 +1,8 @@ +use backend::{axum_app, build_runtime, init_logger, server_main}; + +fn main() { + init_logger(); + let runtime = build_runtime(); + let app = axum_app(); + runtime.block_on(server_main(app)); +} diff --git a/smart-house-web/backend/src/server.rs b/smart-house-web/backend/src/server.rs new file mode 100644 index 0000000..67ee82a --- /dev/null +++ b/smart-house-web/backend/src/server.rs @@ -0,0 +1,94 @@ +use axum::{ + Router, + routing::{delete, get, post, put}, +}; +use std::{process::exit, sync::Arc}; +use tokio::sync::RwLock; +use tower_http::cors::*; +use tracing::{error, info}; + +use smart_house::House; + +/// Тип состояния +type ServerState = Arc>; + +pub fn axum_app() -> Router { + let state: ServerState = Arc::new(RwLock::new(House::default())); + 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) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_headers(Any) + .allow_methods(Any), + ) +} + +/// Основная функция сервера +pub async fn server_main(app: Router) { + let addr = "127.0.0.1:8081"; + 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..d16e0c1 --- /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 smart_house::{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..7a048ae --- /dev/null +++ b/smart-house-web/backend/src/server/device.rs @@ -0,0 +1,44 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; + +use smart_house::{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..6ce4c78 --- /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 smart_house::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..defa127 --- /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 smart_house::{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..eaae719 --- /dev/null +++ b/smart-house-web/backend/test.http @@ -0,0 +1,71 @@ +### DEBUG +GET http://localhost:8081/debug + +### list rooms +GET http://localhost:8081/rooms + +### post all rooms +POST http://localhost:8081/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:8081/room/ROOM + +### add room +PUT http://localhost:8081/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:8081/room/ROOM1 + +### get room devices +GET http://localhost:8081/room/ROOM1/devices + +### get room device +GET http://localhost:8081/room/ROOM/device/TEST + +### get room device +PUT http://localhost:8081/room/ROOM/device/TEST +Content-Type: application/json + +{ + "type": "PowerSocket", + "power_rate": 5, + "on": true +} + +### get room device +DELETE http://localhost:8081/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..d52f51c --- /dev/null +++ b/smart-house-web/backend/tests/api_smoke_test.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; + +use backend::{axum_app, init_logger, server_main}; +use reqwest::{Client, Response, StatusCode}; +use serde_json::json; +use smart_house::{Device, Room}; +use tokio::spawn; +use tracing::info; + +type RqResult = Result; + +#[tokio::test(flavor = "current_thread")] +async fn smoke() -> RqResult<()> { + init_logger(); + spawn(server_main(axum_app())); + + 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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/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:8081/rooms").send().await?; + assert_eq!(resp.status(), StatusCode::OK); + + print_resp(resp).await?; + + Ok(()) +} diff --git a/smart-house-web/frontend/.gitignore b/smart-house-web/frontend/.gitignore new file mode 100644 index 0000000..178135c --- /dev/null +++ b/smart-house-web/frontend/.gitignore @@ -0,0 +1 @@ +/dist/ diff --git a/smart-house-web/frontend/Cargo.toml b/smart-house-web/frontend/Cargo.toml new file mode 100644 index 0000000..01a187d --- /dev/null +++ b/smart-house-web/frontend/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "frontend" +version = "0.1.0" +edition = "2024" + +[dependencies] +smart_house = { path = "../smart_house" } + +console_error_panic_hook = "0.1" +leptos = { version = "0.8", features = ["csr"] } +leptos_router = "0.8" +reqwest = { version = "0.13", features = ["json"] } +serde_json = "1.0" diff --git a/smart-house-web/frontend/README.md b/smart-house-web/frontend/README.md new file mode 100644 index 0000000..5ae60b0 --- /dev/null +++ b/smart-house-web/frontend/README.md @@ -0,0 +1,10 @@ +# Фронтэнд умного дома + +Запуск сервера разработки (sh): + + RUSTFLAGS="--cfg erase_components" trunk serve + +Запуск сервера разработки (powershell): + + $env:RUSTFLAGS = '--cfg erase_components' + trunk serve diff --git a/smart-house-web/frontend/Trunk.toml b/smart-house-web/frontend/Trunk.toml new file mode 100644 index 0000000..34bbf1f --- /dev/null +++ b/smart-house-web/frontend/Trunk.toml @@ -0,0 +1,3 @@ +[[proxy]] +backend = "http://localhost:8081" +rewrite = "/api" diff --git a/smart-house-web/frontend/examples/3_10_children.rs b/smart-house-web/frontend/examples/3_10_children.rs new file mode 100644 index 0000000..2e9a949 --- /dev/null +++ b/smart-house-web/frontend/examples/3_10_children.rs @@ -0,0 +1,92 @@ +#![allow(non_snake_case, unused)] + +use leptos::prelude::*; + +// Often, you want to pass some kind of child view to another +// component. There are two basic patterns for doing this: +// - "render props": creating a component prop that takes a function +// that creates a view +// - the `children` prop: a special property that contains content +// passed as the children of a component in your view, not as a +// property + +#[component] +pub fn App() -> impl IntoView { + let (items, set_items) = signal(vec![0, 1, 2]); + let render_prop = move || { + let len = move || items.read().len(); + view! { +

"Length: " {len}

+ } + }; + + view! { + // This component just displays the two kinds of children, + // embedding them in some other markup + `render_prop` + // (this doesn't work for HTML element attributes) + render_prop + > + // these look just like the children of an HTML element +

"Here's a child."

+

"Here's another child."

+
+
+ // This component actually iterates over and wraps the children + +

"Here's a child."

+

"Here's another child."

+
+ } +} + +/// Displays a `render_prop` and some children within markup. +#[component] +pub fn TakesChildren( + /// Takes a function (type F) that returns anything that can be + /// converted into a View (type IV) + render_prop: F, + /// `children` takes the `Children` type + /// this is an alias for `Box Fragment>` + /// ... aren't you glad we named it `Children` instead? + children: Children, +) -> impl IntoView +where + F: Fn() -> IV, + IV: IntoView, +{ + view! { +

""

+

"Render Prop"

+ {render_prop()} +
+

"Children"

+ {children()} + } +} + +/// Wraps each child in an `
  • ` and embeds them in a `
      `. +#[component] +pub fn WrapsChildren(children: ChildrenFragment) -> impl IntoView { + // children() returns a `Fragment`, which has a + // `nodes` field that contains a Vec + // this means we can iterate over the children + // to create something new! + let children = children() + .nodes + .into_iter() + .map(|child| view! {
    • {child}
    • }) + .collect::>(); + + view! { +

      ""

      + // wrap our wrapped children in a UL +
        {children}
      + } +} + +fn main() { + leptos::mount::mount_to_body(App) +} diff --git a/smart-house-web/frontend/examples/3_4_iteration.rs b/smart-house-web/frontend/examples/3_4_iteration.rs new file mode 100644 index 0000000..6d258e1 --- /dev/null +++ b/smart-house-web/frontend/examples/3_4_iteration.rs @@ -0,0 +1,158 @@ +use leptos::prelude::*; + +// Iteration is a very common task in most applications. +// So how do you take a list of data and render it in the DOM? +// This example will show you the two ways: +// 1) for mostly-static lists, using Rust iterators +// 2) for lists that grow, shrink, or move items, using + +#[component] +fn App() -> impl IntoView { + view! { +

      "Iteration"

      +

      "Static List"

      +

      "Use this pattern if the list itself is static."

      + +

      "Dynamic List"

      +

      "Use this pattern if the rows in your list will change."

      + + } +} + +/// A list of counters, without the ability +/// to add or remove any. +#[component] +fn StaticList( + /// How many counters to include in this list. + length: usize, +) -> impl IntoView { + // create counter signals that start at incrementing numbers + let counters = (1..=length).map(|idx| RwSignal::new(idx)); + + // when you have a list that doesn't change, you can + // manipulate it using ordinary Rust iterators + // and collect it into a Vec<_> to insert it into the DOM + let counter_buttons = counters + .map(|count| { + view! { +
    • + +
    • + } + }) + .collect::>(); + + // Note that if `counter_buttons` were a reactive list + // and its value changed, this would be very inefficient: + // it would rerender every row every time the list changed. + view! { +
        {counter_buttons}
      + } +} + +/// A list of counters that allows you to add or +/// remove counters. +#[component] +fn DynamicList( + /// The number of counters to begin with. + initial_length: usize, +) -> impl IntoView { + // This dynamic list will use the component. + // is a keyed list. This means that each row + // has a defined key. If the key does not change, the row + // will not be re-rendered. When the list changes, only + // the minimum number of changes will be made to the DOM. + + // `next_counter_id` will let us generate unique IDs + // we do this by simply incrementing the ID by one + // each time we create a counter + let mut next_counter_id = initial_length; + + // we generate an initial list as in + // but this time we include the ID along with the signal + // see NOTE in add_counter below re: ArcRwSignal + let initial_counters = (0..initial_length) + .map(|id| (id, ArcRwSignal::new(id + 1))) + .collect::>(); + + // now we store that initial list in a signal + // this way, we'll be able to modify the list over time, + // adding and removing counters, and it will change reactively + let (counters, set_counters) = signal(initial_counters); + + let add_counter = move |_| { + // create a signal for the new counter + // we use ArcRwSignal here, instead of RwSignal + // ArcRwSignal is a reference-counted type, rather than the arena-allocated + // signal types we've been using so far. + // When we're creating a collection of signals like this, using ArcRwSignal + // allows each signal to be deallocated when its row is removed. + let sig = ArcRwSignal::new(next_counter_id + 1); + // add this counter to the list of counters + set_counters.update(move |counters| { + // since `.update()` gives us `&mut T` + // we can just use normal Vec methods like `push` + counters.push((next_counter_id, sig)) + }); + // increment the ID so it's always unique + next_counter_id += 1; + }; + + view! { +
      + +
        + // The component is central here + // This allows for efficient, key list rendering + instead of + each=move || counters.get() + // the key should be unique and stable for each row + // using an index is usually a bad idea, unless your list + // can only grow, because moving items around inside the list + // means their indices will change and they will all rerender + key=|counter| counter.0 + // `children` receives each item from your `each` iterator + // and returns a view + children=move |(id, count)| { + // we can convert our ArcRwSignal to a Copy-able RwSignal + // for nicer DX when moving it into the view + let count = RwSignal::from(count); + view! { +
      • + + +
      • + } + } + /> +
      +
      + } +} + +fn main() { + leptos::mount::mount_to_body(App) +} diff --git a/smart-house-web/frontend/examples/3_6_form_and_inputs.rs b/smart-house-web/frontend/examples/3_6_form_and_inputs.rs new file mode 100644 index 0000000..7804d9d --- /dev/null +++ b/smart-house-web/frontend/examples/3_6_form_and_inputs.rs @@ -0,0 +1,99 @@ +use leptos::ev::SubmitEvent; +use leptos::prelude::*; + +#[component] +fn App() -> impl IntoView { + view! { +

      "Controlled Component"

      + +

      "Uncontrolled Component"

      + + } +} + +#[component] +fn ControlledComponent() -> impl IntoView { + // create a signal to hold the value + let (name, set_name) = signal("Controlled".to_string()); + + view! { + +

      "Name is: " {name}

      + } +} + +#[component] +fn UncontrolledComponent() -> impl IntoView { + // import the type for + use leptos::html::Input; + + let (name, set_name) = signal("Uncontrolled".to_string()); + + // we'll use a NodeRef to store a reference to the input element + // this will be filled when the element is created + let input_element: NodeRef = NodeRef::new(); + + // fires when the form `submit` event happens + // this will store the value of the in our signal + let on_submit = move |ev: SubmitEvent| { + // stop the page from reloading! + ev.prevent_default(); + + // here, we'll extract the value from the input + let value = input_element + .get() + // event handlers can only fire after the view + // is mounted to the DOM, so the `NodeRef` will be `Some` + .expect(" to exist") + // `NodeRef` implements `Deref` for the DOM element type + // this means we can call`HtmlInputElement::value()` + // to get the current value of the input + .value(); + set_name.set(value); + }; + + view! { +
      + + +
      +

      "Name is: " {name}

      + } +} + +// This `main` function is the entry point into the app +// It just mounts our component to the +// Because we defined it as `fn App`, we can now use it in a +// template as +fn main() { + leptos::mount::mount_to_body(App) +} diff --git a/smart-house-web/frontend/index.html b/smart-house-web/frontend/index.html new file mode 100644 index 0000000..7f2ad66 --- /dev/null +++ b/smart-house-web/frontend/index.html @@ -0,0 +1,5 @@ + + + + + diff --git a/smart-house-web/frontend/src/device.rs b/smart-house-web/frontend/src/device.rs new file mode 100644 index 0000000..44ea3aa --- /dev/null +++ b/smart-house-web/frontend/src/device.rs @@ -0,0 +1,60 @@ +use leptos::logging::*; +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_router::hooks::use_params_map; +use reqwest::StatusCode; +use serde_json::Value; + +async fn load_device(room: &str, dev: &str, write_signal: WriteSignal) { + let resp = reqwest::Client::new() + .get(format!("http://localhost:8081/room/{room}/device/{dev}")) + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot fetch device"); + return; + }; + if resp.status() != StatusCode::OK { + debug_log!("Device fetching error"); + return; + }; + let value = match resp.json::().await { + Ok(value) => value, + Err(e) => { + debug_log!("error parsing device: {:?}", e); + return; + } + }; + let Ok(json) = serde_json::to_string_pretty(&value) else { + debug_log!("can not print json value"); + return; + }; + write_signal.set(json); +} + +#[component] +pub fn Device() -> impl IntoView { + let params_map = use_params_map(); + let room = params_map.read().get("room").unwrap_or_default(); + let dev = params_map.read().get("dev").unwrap_or_default(); + + let (dev_read, dev_write) = signal(String::default()); + + Effect::new(move |_| { + let room = params_map.read().get("room").unwrap_or_default(); + let dev = params_map.read().get("dev").unwrap_or_default(); + spawn_local(async move { + load_device(&room, &dev, dev_write).await; + }); + }); + + view! { +

      {move || dev.clone()}

      +

      + {"<= BACK"} +

      +
      +            {dev_read}
      +        
      + } +} diff --git a/smart-house-web/frontend/src/house.rs b/smart-house-web/frontend/src/house.rs new file mode 100644 index 0000000..1e55d0e --- /dev/null +++ b/smart-house-web/frontend/src/house.rs @@ -0,0 +1,168 @@ +use leptos::logging::*; +use leptos::prelude::*; +use leptos::task::spawn_local; +use reqwest::StatusCode; +use serde_json::Value; + +async fn load_rooms(rooms_write_signal: WriteSignal>) { + let resp = reqwest::Client::new() + .get("http://localhost:8081/rooms") + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot fetch rooms"); + return; + }; + if resp.status() != StatusCode::OK { + debug_log!("Rooms fetching error"); + return; + }; + let value = match resp.json::().await { + Ok(value) => value, + Err(e) => { + debug_log!("error parsing rooms: {:?}", e); + return; + } + }; + let value = match value { + Value::Object(value) => value, + _ => { + return; + } + }; + let mut rooms = Vec::with_capacity(value.len()); + for (name, _) in value.into_iter() { + rooms.push(name); + } + rooms_write_signal.set(rooms); +} + +async fn add_room(name: &str) { + let resp = reqwest::Client::new() + .put(format!("http://localhost:8081/room/{}", name)) + .header("Content-Type", "application/json") + .body("{\"devices\":{}}") + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot call room creation"); + return; + }; + if resp.status() != StatusCode::CREATED { + debug_log!("Room creation is not succeeded"); + }; +} + +async fn remove_room(name: &str) { + let resp = reqwest::Client::new() + .delete(format!("http://localhost:8081/room/{}", name)) + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot call room removing"); + return; + }; + if resp.status() != StatusCode::ACCEPTED { + debug_log!("Room removing is not succeeded"); + }; +} + +async fn get_report(write_signal: WriteSignal) { + let resp = reqwest::Client::new() + .get("http://localhost:8081/rooms") + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot fetch rooms"); + return; + }; + if resp.status() != StatusCode::OK { + debug_log!("Rooms fetching error"); + return; + }; + let value = match resp.json::().await { + Ok(value) => value, + Err(e) => { + debug_log!("error parsing rooms: {:?}", e); + return; + } + }; + let Ok(json) = serde_json::to_string_pretty(&value) else { + debug_log!("can not print json value"); + return; + }; + write_signal.set(json); +} + +#[component] +pub fn House() -> impl IntoView { + use leptos::html::Input; + + let (rooms, set_rooms) = signal(Vec::::new()); + let (report_read, report_write) = signal(String::default()); + let input_element: NodeRef = NodeRef::new(); + + let remove_room = move |name: String| { + spawn_local(async move { + remove_room(&name).await; + load_rooms(set_rooms).await; + }); + }; + + let add_room = move |_| { + let name = input_element.get().expect(" to exist").value(); + spawn_local(async move { + add_room(&name).await; + load_rooms(set_rooms).await; + }); + }; + + let refresh_report = move |_| { + spawn_local(async move { + get_report(report_write).await; + }); + }; + + Effect::new(move |_| { + spawn_local(async move { + load_rooms(set_rooms).await; + }); + }); + + view! { +

      ROOMS

      + +
      + + +
      +
      +
      + +
      +
      {report_read}
      + } +} diff --git a/smart-house-web/frontend/src/main.rs b/smart-house-web/frontend/src/main.rs new file mode 100644 index 0000000..8cc6672 --- /dev/null +++ b/smart-house-web/frontend/src/main.rs @@ -0,0 +1,26 @@ +use leptos::prelude::*; +use leptos_router::components::{Route, Router, Routes}; +use leptos_router::path; + +#[component] +pub fn App() -> impl IntoView { + view! { + +
      + + + + + +
      +
      + } +} + +fn main() { + leptos::mount::mount_to_body(App) +} + +mod device; +mod house; +mod room; diff --git a/smart-house-web/frontend/src/room.rs b/smart-house-web/frontend/src/room.rs new file mode 100644 index 0000000..7927844 --- /dev/null +++ b/smart-house-web/frontend/src/room.rs @@ -0,0 +1,207 @@ +use std::collections::HashMap; + +use leptos::html::Input; +use leptos::logging::*; +use leptos::prelude::*; +use leptos::task::spawn_local; +use leptos_router::hooks::use_params_map; +use reqwest::StatusCode; +use serde_json::Value; +use serde_json::json; +use smart_house::*; + +async fn load_devices(room: &str, rooms_write_signal: WriteSignal>) { + let resp = reqwest::Client::new() + .get(format!("http://localhost:8081/room/{room}/devices")) + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot fetch devices"); + return; + }; + if resp.status() != StatusCode::OK { + debug_log!("Devices fetching error"); + return; + }; + let value = match resp.json::>().await { + Ok(value) => value, + Err(e) => { + debug_log!("error parsing devices: {:?}", e); + return; + } + }; + let mut devices = Vec::with_capacity(value.len()); + for (name, dev) in value.into_iter() { + devices.push((name, dev.report())); + } + rooms_write_signal.set(devices); +} + +async fn add_device(room: &str, name: &str, device: Value) { + let resp = reqwest::Client::new() + .put(format!("http://localhost:8081/room/{room}/device/{name}")) + .json(&device) + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot call device creation"); + return; + }; + if resp.status() != StatusCode::CREATED { + debug_log!("Device creation is not succeeded"); + }; +} + +async fn remove_device(room: &str, name: &str) { + let resp = reqwest::Client::new() + .delete(format!("http://localhost:8081/room/{room}/device/{name}")) + .send() + .await; + let Ok(resp) = resp else { + debug_log!("Cannot call device removing"); + return; + }; + if resp.status() != StatusCode::ACCEPTED { + debug_log!("Device removing is not succeeded"); + }; +} + +#[component] +pub fn Room() -> impl IntoView { + let params_map = use_params_map(); + let (devs_read, devs_write) = signal(Vec::<(String, String)>::new()); + + let ps_name_input = NodeRef::::new(); + let ps_power_input = NodeRef::::new(); + let ps_on_input = NodeRef::::new(); + let trm_name_input = NodeRef::::new(); + let trm_temp_input = NodeRef::::new(); + + Effect::new(move |_| { + let room_name = params_map.read().get("name").unwrap_or_default(); + spawn_local(async move { + load_devices(&room_name, devs_write).await; + }); + }); + + let add_ps = move |_| { + let ps_name = ps_name_input + .get() + .map(|e| e.value()) + .unwrap_or("".to_string()); + let ps_power = ps_power_input + .get() + .map(|e| e.value_as_number()) + .unwrap_or(0.0); + let ps_on = ps_on_input.get().map(|e| e.checked()).unwrap_or(false); + + let dev = json!({ + "type": "PowerSocket", + "power_rate": ps_power, + "on": ps_on + }); + let room_name = params_map.read().get("name").unwrap_or_default(); + spawn_local(async move { + add_device(&room_name, &ps_name, dev).await; + load_devices(&room_name, devs_write).await; + }); + }; + let add_trm = move |_| { + let trm_name = trm_name_input + .get() + .map(|e| e.value()) + .unwrap_or("".to_string()); + let trm_temp = trm_temp_input + .get() + .map(|e| e.value_as_number()) + .unwrap_or(0.0); + let dev = json!({ + "type": "Thermometer", + "temperature": trm_temp + }); + let room_name = params_map.read().get("name").unwrap_or_default(); + spawn_local(async move { + add_device(&room_name, &trm_name, dev).await; + load_devices(&room_name, devs_write).await; + }); + }; + + let rm_dev = move |name: String| { + let room_name = params_map.read().get("name").unwrap_or_default(); + spawn_local(async move { + remove_device(&room_name, &name).await; + load_devices(&room_name, devs_write).await; + }); + }; + + let room_name = params_map.read().get("name").unwrap_or_default(); + + view! { +

      {move || params_map.read().get("name").unwrap_or_default()}

      +

      + {"<= BACK"} +

      + + + + + + + + + + + + + + + } } + /> + +
      device namedevice stateremove
      + {dev.0.clone()} + {dev.1} + +
      +
      + + + + + +
      +
      + + + + +
      + } +} diff --git a/smart-house-web/smart_house/Cargo.toml b/smart-house-web/smart_house/Cargo.toml new file mode 100644 index 0000000..fce5e4e --- /dev/null +++ b/smart-house-web/smart_house/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "smart_house" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = "1.0" diff --git a/smart-house-web/smart_house/src/lib.rs b/smart-house-web/smart_house/src/lib.rs new file mode 100644 index 0000000..eb906de --- /dev/null +++ b/smart-house-web/smart_house/src/lib.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); + } +}