chalkydri/api/
mod.rs

1//!
2//! JSON API used by the web UI and possibly third-party applications
3//!
4
5use actix_web::{
6    App, HttpResponse, HttpServer, Responder, get,
7    http::{
8        StatusCode,
9        header::{self, CacheDirective},
10    },
11    post, put,
12    web::{self, Data},
13};
14use mime_guess::from_path;
15use rust_embed::Embed;
16use rustix::system::RebootCommand;
17use sysinfo::System;
18use utopia::{OpenApi, ToSchema};
19
20use crate::{Cfg, cameras::CameraManager, config::Config};
21
22#[derive(OpenApi)]
23#[openapi(
24    info(
25        title = "Chalkydri Manager API",
26        version = env!("CARGO_PKG_VERSION"),
27    ),
28    paths(
29        info,
30        configuration,
31        configure,
32        save_configuration,
33        calibration_intrinsics,
34        calibration_status,
35        calibration_step,
36        sys_info,
37        restart,
38        sys_reboot,
39        sys_shutdown,
40    )
41)]
42#[allow(dead_code)]
43struct ApiDoc;
44
45#[derive(rust_embed::Embed)]
46#[folder = "../../ui/build/"]
47struct Assets;
48
49fn handle_embedded_file(path: &str) -> HttpResponse {
50    match <Assets as Embed>::get(path) {
51        Some(content) => HttpResponse::Ok()
52            .content_type(from_path(path).first_or_octet_stream().as_ref())
53            .body(content.data.into_owned()),
54        None => HttpResponse::NotFound().body("404 Not Found"),
55    }
56}
57
58#[get("/")]
59async fn index() -> impl Responder {
60    handle_embedded_file("index.html")
61}
62
63#[get("/api/openapi.json")]
64async fn openapi_json() -> impl Responder {
65    web::Json(ApiDoc::openapi())
66}
67
68#[get("/{_:.*}")]
69async fn dist(path: web::Path<String>) -> impl Responder {
70    if Assets::get(path.as_str()).is_some() {
71        handle_embedded_file(path.as_str()).map_into_boxed_body()
72    } else {
73        HttpResponse::TemporaryRedirect()
74            .insert_header(("Location", "/"))
75            .body(())
76            .map_into_boxed_body()
77    }
78}
79
80/// Run the API server
81pub async fn run_api(cam_man: CameraManager) {
82    HttpServer::new(move || {
83        App::new()
84            .app_data(Data::new(cam_man.clone()))
85            .service(index)
86            .service(info)
87            .service(configuration)
88            .service(configure)
89            .service(save_configuration)
90            .service(calibration_intrinsics)
91            .service(calibration_status)
92            .service(calibration_step)
93            .service(restart)
94            .service(sys_reboot)
95            .service(sys_shutdown)
96            .service(sys_info)
97            .service(openapi_json)
98            .service(stream)
99            .service(dist)
100    })
101    .bind(("0.0.0.0", 6942))
102    .unwrap()
103    .run()
104    .await
105    .unwrap();
106}
107
108#[derive(Serialize, ToSchema)]
109pub struct Info {
110    pub version: &'static str,
111    pub cpu_usage: u8,
112    pub mem_usage: u8,
113}
114
115/// Get Chalkydri's version and system information
116#[utopia::path(
117    responses(
118        (status = 200, body = Info),
119    ),
120)]
121#[get("/api/info")]
122pub(super) async fn info() -> impl Responder {
123    let mut system = System::new();
124    system.refresh_cpu_usage();
125    system.refresh_memory();
126
127    let cpu_usage = (system.global_cpu_usage() * 100.0) as u8;
128    let mem_usage = ((system.used_memory() as f64 / system.total_memory() as f64) * 100.0) as u8;
129
130    web::Json(Info {
131        version: env!("CARGO_PKG_VERSION"),
132        cpu_usage,
133        mem_usage,
134    })
135}
136
137/// List possible configurations
138#[utopia::path(
139    responses(
140        (status = 200, body = Config),
141    ),
142)]
143#[get("/api/configuration")]
144pub(super) async fn configuration(data: web::Data<CameraManager>) -> impl Responder {
145    let cam_man = data.get_ref();
146
147    let mut cfgg = Cfg.read().await.clone();
148    for cam in cam_man.devices() {
149        if let Some(cameras) = &mut cfgg.cameras {
150            if cameras.iter().filter(|c| c.id == cam.id).next().is_none() {
151                cameras.push(cam);
152            }
153        } else {
154            cfgg.cameras = Some(Vec::new());
155            if let Some(cameras) = &mut cfgg.cameras {
156                cameras.push(cam);
157            }
158        }
159    }
160    web::Json(cfgg)
161}
162
163/// Set the configuration without saving it to the disk
164#[utopia::path(
165    responses(
166        (status = 200, body = Config),
167    ),
168)]
169#[post("/api/configuration")]
170pub(super) async fn configure(
171    data: web::Data<CameraManager>,
172    web::Json(cfgg): web::Json<Config>,
173) -> impl Responder {
174    let cam_man = data.get_ref();
175
176    *Cfg.write().await = cfgg;
177
178    for cam in cam_man.devices() {
179        cam_man.update_pipeline(cam.id.clone()).await;
180    }
181
182    web::Json(Cfg.read().await.clone())
183}
184
185/// Save the configuration to disk
186#[utopia::path(
187    responses(
188        (status = 200, body = Config),
189    ),
190)]
191#[put("/api/configuration")]
192pub(super) async fn save_configuration(web::Json(cfgg): web::Json<Config>) -> impl Responder {
193    let old_config = Cfg.read().await.clone();
194
195    if cfgg.device_name != old_config.device_name {
196        rustix::system::sethostname(cfgg.device_name.clone().unwrap().as_bytes()).unwrap();
197    }
198
199    cfgg.save("chalkydri.toml").await.unwrap();
200
201    web::Json(cfgg)
202}
203
204/// Calibrate the given camera's intrinsic parameters
205#[utopia::path(
206    responses(
207        (status = 200),
208    ),
209)]
210#[get("/api/calibrate/{cam_name}/intrinsics")]
211pub(super) async fn calibration_intrinsics(
212    path: web::Path<String>,
213    data: web::Data<CameraManager>,
214) -> impl Responder {
215    let cam_name = path.to_string();
216
217    let cam_man = data.get_ref();
218    let calibrated_model = cam_man
219        .calibrators()
220        .await
221        .get_mut(&cam_name)
222        .unwrap()
223        .calibrate()
224        .unwrap();
225    {
226        let json = serde_json::to_value(calibrated_model).unwrap();
227        let cfgg = &mut (*Cfg.write().await);
228        if let Some(cams) = &mut cfgg.cameras {
229            (*cams)
230                .iter_mut()
231                .filter(|cam| cam.id == cam_name)
232                .next()
233                .unwrap()
234                .calib = Some(json);
235        }
236    }
237
238    HttpResponse::new(StatusCode::OK)
239}
240
241#[derive(Serialize, ToSchema)]
242struct CalibrationStatus {
243    width: u32,
244    height: u32,
245    current_step: usize,
246    total_steps: usize,
247}
248
249#[utopia::path(
250    responses(
251        (status = 200, body = CalibrationStatus),
252    ),
253)]
254#[get("/api/calibrate/status")]
255pub(super) async fn calibration_status(data: web::Data<CameraManager>) -> impl Responder {
256    let cam_man = data.get_ref();
257
258    web::Json(CalibrationStatus {
259        width: 1280,
260        height: 720,
261        current_step: 0,
262        total_steps: 200,
263    })
264}
265
266/// Complete a calibration step for the given camera
267#[utopia::path(
268    responses(
269        (status = 200),
270    ),
271)]
272#[get("/api/calibrate/{cam_name}/step")]
273pub(super) async fn calibration_step(
274    path: web::Path<String>,
275    data: web::Data<CameraManager>,
276) -> impl Responder {
277    let cam_name = path.to_string();
278
279    let cam_man = data.get_ref();
280    let current_step = cam_man.calib_step(cam_name).await;
281
282    web::Json(CalibrationStatus {
283        width: 1280,
284        height: 720,
285        current_step,
286        total_steps: 200,
287    })
288}
289
290/// Restart Chalkydri
291#[utopia::path(
292    responses(
293        (status = 200),
294    )
295)]
296#[post("/api/restart")]
297pub(super) async fn restart(data: web::Data<CameraManager>) -> impl Responder {
298    data.restart().await;
299
300    HttpResponse::Ok().await.unwrap()
301}
302
303/// Restart the system
304#[utopia::path(
305    responses(
306        (status = 200),
307    )
308)]
309#[post("/api/sys/reboot")]
310pub(super) async fn sys_reboot() -> impl Responder {
311    rustix::system::reboot(RebootCommand::Restart).unwrap();
312
313    web::Json(())
314}
315
316/// Power off the system
317#[utopia::path(
318    responses(
319        (status = 200),
320    )
321)]
322#[post("/api/sys/shutdown")]
323pub(super) async fn sys_shutdown() -> impl Responder {
324    rustix::system::reboot(RebootCommand::PowerOff).unwrap();
325
326    web::Json(())
327}
328
329#[derive(Serialize)]
330struct SysInfo {
331    uptime: u64,
332    mem_usage: u8,
333}
334
335/// Get system information
336#[utopia::path(
337    responses(
338        (status = 200),
339    )
340)]
341#[get("/api/sys/info")]
342pub(super) async fn sys_info() -> impl Responder {
343    let sysinfo = rustix::system::sysinfo();
344    let mut system = sysinfo::System::new();
345    system.refresh_cpu_usage();
346
347    let uptime = sysinfo.uptime as u64;
348    let mem_usage =
349        (((sysinfo.totalram - sysinfo.freeram) as f32 / sysinfo.totalram as f32) * 100.0) as u8;
350
351    web::Json(SysInfo { uptime, mem_usage })
352}
353
354/// Get an MJPEG camera stream for the given camera
355#[get("/stream/{cam_name}")]
356pub(super) async fn stream(
357    path: web::Path<String>,
358    data: web::Data<CameraManager>,
359) -> impl Responder {
360    let cam_name = path.clone();
361
362    println!("{cam_name}");
363
364    if let Some(mjpeg_stream) = data.mjpeg_streams().await.get(&cam_name) {
365        HttpResponse::Ok()
366            .append_header(header::CacheControl(vec![CacheDirective::NoCache]))
367            .append_header((header::PRAGMA, "no-cache"))
368            .append_header((header::EXPIRES, 0))
369            .append_header((header::CONNECTION, "close"))
370            .append_header((
371                header::CONTENT_TYPE,
372                "multipart/x-mixed-replace; boundary=frame",
373            ))
374            .streaming(mjpeg_stream.clone())
375    } else {
376        HttpResponse::NotFound().await.unwrap()
377    }
378}