Compare commits
3 commits
331971da81
...
0da1a50058
Author | SHA1 | Date | |
---|---|---|---|
|
0da1a50058 | ||
|
799c891475 | ||
|
3d1673d3f5 |
46 changed files with 5466 additions and 0 deletions
28
api-docs/index.html
Normal file
28
api-docs/index.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Swagger</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="//unpkg.com/swagger-ui-dist@3/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="//unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/api-docs/openapi.json",
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
requestInterceptor: (request) => {
|
||||
request.headers['X-CSRFToken'] = "{{ csrf_token }}"
|
||||
return request;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
194
api-docs/openapi.json
Normal file
194
api-docs/openapi.json
Normal file
|
@ -0,0 +1,194 @@
|
|||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"title": "R/AGB",
|
||||
"description": "Une API pour choisir les couleurs en agb. Il existe un endpoint SSE pour être notifié des mises à jour des couleurs à l'addresse [https://agb.hackens.org/api/sse](https://agb.hackens.org/api/sse). Une description du plan d'addressage au format JSON est disponible: [/api-docs/patch.json](/api-docs/patch.json). On peut récupérer le token d'API à l'addresse: [/token](/token)."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://agb.hackens.org/api"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/values": {
|
||||
"get": {
|
||||
"summary": "Get the colors for all the channels",
|
||||
"operationId": "getValues",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of colors",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Color"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Change multiple colors",
|
||||
"description": "Change multiple colors. This request has a cooldown of 500ms.",
|
||||
"operationId": "changeColors",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": [
|
||||
"exp",
|
||||
"scope",
|
||||
"user",
|
||||
"sub"
|
||||
]
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Colors to change",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Color"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Color updated successfully",
|
||||
"content": {
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "You're being rate-limited",
|
||||
"content": {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/values/{id}": {
|
||||
"get": {
|
||||
"summary": "Get channel colors by ID",
|
||||
"operationId": "find color by id",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "ID of color to fetch",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Color of specific channel",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Color"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Changes channel color",
|
||||
"description": "Changes channel color",
|
||||
"operationId": "changeColor",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "ID of color to fetch",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": [
|
||||
"exp",
|
||||
"scope",
|
||||
"user",
|
||||
"sub"
|
||||
]
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "Color to change",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Color"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Color updated successfully",
|
||||
"content": {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Color": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"red",
|
||||
"green",
|
||||
"blue"
|
||||
],
|
||||
"properties": {
|
||||
"red": {
|
||||
"type": "integer",
|
||||
"format": "int8"
|
||||
},
|
||||
"green": {
|
||||
"type": "integer",
|
||||
"format": "int8"
|
||||
},
|
||||
"blue": {
|
||||
"type": "integer",
|
||||
"format": "int8"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1651
backend/Cargo.lock
generated
Normal file
1651
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
backend/Cargo.toml
Normal file
21
backend/Cargo.toml
Normal file
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "ragb-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7.7", features = ["macros"] }
|
||||
axum-extra = { version = "0.9.4", features = ["typed-header"] }
|
||||
dotenvy = "0.15.7"
|
||||
jsonwebtoken = "9.3.0"
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
tokio-stream = { version = "0.1.16", features = ["sync"] }
|
||||
tower = "0.5.1"
|
||||
tower-http = { version = "0.6.1", features = ["tracing", "trace", "cors"] }
|
||||
tower_governor = { version = "0.4.2", features = ["axum"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
1
backend/README.md
Normal file
1
backend/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
This is the backend of ragb. It provides a simple CRUD with in memory database.
|
2
backend/auth_curl.sh
Executable file
2
backend/auth_curl.sh
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyYWdiIiwic2NvcGUiOiJtb2RpZnkiLCJleHAiOjE3Mzk0ODY3MDd9.J8Qh12hxQ9rZFKhpjcth2uUrEhnFVH8PAVmkVgM1wIo" $@
|
61
backend/src/authorization.rs
Normal file
61
backend/src/authorization.rs
Normal file
|
@ -0,0 +1,61 @@
|
|||
use crate::model::DB;
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::{headers, typed_header::TypedHeader};
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
scope: String,
|
||||
user: String,
|
||||
is_cof: bool,
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
name: String,
|
||||
pub is_cof: bool,
|
||||
}
|
||||
|
||||
fn check_token(token: &str, jwt: &str) -> Option<User> {
|
||||
let decoded_token = decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(jwt.as_bytes()),
|
||||
&Validation::new(Algorithm::HS256),
|
||||
);
|
||||
match decoded_token {
|
||||
Ok(token_data) => {
|
||||
let user = token_data.claims.user;
|
||||
let is_cof =token_data.claims.is_cof;
|
||||
if token_data.claims.scope == "modify" {
|
||||
tracing::info!("Successful auth {user:?}");
|
||||
Some(User { name: user, is_cof })
|
||||
} else {
|
||||
tracing::debug!("Failed auth: {user:?} don't have modify scope");
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(err) => {tracing::debug!("Failed decoding token: {err:?}"); None},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn jwt_middleware(
|
||||
State(state): State<DB>,
|
||||
TypedHeader(auth): TypedHeader<headers::Authorization<headers::authorization::Bearer>>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let token = auth.token();
|
||||
if let Some(user) = check_token(token, &state.static_state.jwt_key) {
|
||||
request.extensions_mut().insert(user);
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Err(StatusCode::FORBIDDEN)
|
||||
}
|
||||
}
|
153
backend/src/handler.rs
Normal file
153
backend/src/handler.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
use crate::authorization::User;
|
||||
use crate::model::{ColorArray, DMXAtom, DMXBeam, DMXBeamChange, DMXColorAtom, DMXColor, DB};
|
||||
use axum::{
|
||||
debug_handler,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{sse::Event, IntoResponse, Sse},
|
||||
Extension, Json,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::{self as stream};
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn healthcheck_handler() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn list_values_handler(State(db): State<DB>) -> impl IntoResponse {
|
||||
let val;
|
||||
{
|
||||
let lock = db.mut_state.read().await;
|
||||
val = lock.dmx.clone();
|
||||
}
|
||||
Json(val)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn batch_edit_value_handler(
|
||||
State(db): State<DB>,
|
||||
Extension(user): Extension<User>,
|
||||
Json(body): Json<Vec<DMXColorAtom>>,
|
||||
) -> Result<(), StatusCode> {
|
||||
let mut lock = db.mut_state.write().await;
|
||||
if lock.ratelimit_info.get(&user).map(|d| d.elapsed() <= Duration::from_millis(500)).unwrap_or(false){
|
||||
return Err(StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
for i in &body {
|
||||
check_id(i.address, &lock.dmx.colors)?;
|
||||
}
|
||||
for i in &body {
|
||||
lock.dmx.colors[i.address] = i.value;
|
||||
match db.static_state.change_channel.send(DMXAtom::Color(*i)) {
|
||||
Ok(_) => (),
|
||||
Err(_) => (),
|
||||
}
|
||||
}
|
||||
lock.ratelimit_info.insert(user, Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_value_handler(
|
||||
Path(id): Path<usize>,
|
||||
State(db): State<DB>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let lock = db.mut_state.read().await;
|
||||
check_id(id, &lock.dmx.colors)?;
|
||||
Ok(Json(lock.dmx.colors[id]))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn edit_value_handler(
|
||||
Path(id): Path<usize>,
|
||||
State(db): State<DB>,
|
||||
Json(body): Json<DMXColor>,
|
||||
) -> Result<(), StatusCode> {
|
||||
let mut lock = db.mut_state.write().await;
|
||||
check_id(id, &lock.dmx.colors)?;
|
||||
lock.dmx.colors[id] = body;
|
||||
match db
|
||||
.static_state
|
||||
.change_channel
|
||||
.send(DMXAtom::Color(DMXColorAtom::new(id, body)))
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(_) => (),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn get_motor_value_handler(
|
||||
State(db): State<DB>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let lock = db.mut_state.read().await;
|
||||
Ok(Json(lock.dmx.motor))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn edit_motor_value_handler(
|
||||
State(db): State<DB>,
|
||||
Extension(user): Extension<User>,
|
||||
Json(body): Json<DMXBeamChange>,
|
||||
) -> Result<(), StatusCode> {
|
||||
if !user.is_cof {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
let mut lock = db.mut_state.write().await;
|
||||
lock.dmx.motor = DMXBeam {
|
||||
pan: body.pan.unwrap_or(lock.dmx.motor.pan),
|
||||
tilt: body.tilt.unwrap_or(lock.dmx.motor.tilt),
|
||||
focus: body.focus.unwrap_or(lock.dmx.motor.focus),
|
||||
};
|
||||
let _ = db
|
||||
.static_state
|
||||
.change_channel
|
||||
.send(DMXAtom::Motor(lock.dmx.motor))
|
||||
;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn sse_handler(State(db): State<DB>) -> impl IntoResponse {
|
||||
let rx = db.static_state.change_channel.subscribe();
|
||||
let data: Vec<DMXColor>;
|
||||
let motor: DMXBeam;
|
||||
{
|
||||
let lock = db.mut_state.read().await;
|
||||
data = lock.dmx.colors.clone();
|
||||
motor = lock.dmx.motor.clone();
|
||||
}
|
||||
let init_data = data.into_iter().enumerate().map(|(i, x)| {
|
||||
Ok(DMXAtom::Color(DMXColorAtom {
|
||||
address: i,
|
||||
value: x,
|
||||
}))
|
||||
}).chain(std::iter::once(Ok(DMXAtom::Motor(motor))));
|
||||
let init = stream::iter(init_data);
|
||||
let stream = init
|
||||
.chain(stream::wrappers::BroadcastStream::new(rx))
|
||||
.filter_map(|item| match item {
|
||||
Ok(val) => Some(Event::default().json_data(val)),
|
||||
Err(_) => None,
|
||||
});
|
||||
|
||||
Sse::new(stream).keep_alive(
|
||||
axum::response::sse::KeepAlive::new()
|
||||
.interval(Duration::from_secs(25))
|
||||
.text("ping"),
|
||||
)
|
||||
}
|
||||
|
||||
fn check_id(id: usize, val: &ColorArray) -> Result<(), StatusCode> {
|
||||
if id >= val.len() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
};
|
||||
Ok(())
|
||||
}
|
36
backend/src/main.rs
Normal file
36
backend/src/main.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
mod authorization;
|
||||
mod handler;
|
||||
mod model;
|
||||
mod route;
|
||||
|
||||
use dotenvy;
|
||||
use route::create_router;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
"info,axum::rejection=trace".into()
|
||||
}),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
match dotenvy::dotenv() {
|
||||
Err(_) => tracing::info!(".env file not found"),
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let binding = dotenvy::var("BIND").unwrap_or(String::from("127.0.0.1:9999"));
|
||||
let bind = binding.trim();
|
||||
tracing::debug!("Trying to bind at {bind}");
|
||||
let listener = tokio::net::TcpListener::bind(bind).await.unwrap();
|
||||
let app = create_router()
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
);
|
||||
tracing::info!("🚀 Server started successfully");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
131
backend/src/model.rs
Normal file
131
backend/src/model.rs
Normal file
|
@ -0,0 +1,131 @@
|
|||
use crate::authorization::User;
|
||||
use dotenvy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, Copy, Clone)]
|
||||
pub struct DMXColor {
|
||||
pub red: u8,
|
||||
pub green: u8,
|
||||
pub blue: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, Copy, Clone)]
|
||||
pub struct DMXBeam {
|
||||
pub pan: u8,
|
||||
pub tilt: u8,
|
||||
pub focus: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, Copy, Clone)]
|
||||
pub struct DMXBeamChange {
|
||||
pub pan: Option<u8>,
|
||||
pub tilt: Option<u8>,
|
||||
pub focus: Option<u8>,
|
||||
}
|
||||
|
||||
pub type ColorArray = Vec<DMXColor>;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum DMXAtom {
|
||||
Color(DMXColorAtom),
|
||||
Motor(DMXBeam),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Copy, Clone)]
|
||||
pub struct DMXColorAtom {
|
||||
pub address: usize,
|
||||
pub value: DMXColor,
|
||||
}
|
||||
|
||||
impl DMXColorAtom {
|
||||
pub fn new(address: usize, value: DMXColor) -> DMXColorAtom {
|
||||
DMXColorAtom { address, value }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct DMXState {
|
||||
pub colors: ColorArray,
|
||||
pub motor: DMXBeam,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub dmx: DMXState,
|
||||
pub ratelimit_info: HashMap<User, Instant>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(size: usize, save_path: &str) -> AppState {
|
||||
let data: Result<DMXState, ()> =
|
||||
match fs::File::open(save_path).map(std::io::BufReader::new) {
|
||||
Ok(read) => serde_json::from_reader(read)
|
||||
.map(|mut v: DMXState| {
|
||||
v.colors.resize(
|
||||
size,
|
||||
DMXColor {
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
},
|
||||
);
|
||||
v
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::debug!("Error loading data: {e}");
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::debug!("Error loading data: {e}");
|
||||
Err(())
|
||||
}
|
||||
};
|
||||
let dmx = data.unwrap_or(DMXState {
|
||||
colors: vec![DMXColor::default(); size],
|
||||
motor: DMXBeam::default(),
|
||||
});
|
||||
|
||||
AppState {
|
||||
dmx,
|
||||
ratelimit_info: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StaticState {
|
||||
pub jwt_key: String,
|
||||
pub change_channel: broadcast::Sender<DMXAtom>,
|
||||
pub save_path: String,
|
||||
}
|
||||
|
||||
pub struct SharedState {
|
||||
pub static_state: StaticState,
|
||||
pub mut_state: RwLock<AppState>,
|
||||
}
|
||||
|
||||
pub type DB = Arc<SharedState>;
|
||||
|
||||
pub fn make_db() -> DB {
|
||||
let state_size: usize =
|
||||
usize::from_str(&dotenvy::var("NB_DMX_VALUES").unwrap_or(String::from("100"))).unwrap();
|
||||
let save_path = dotenvy::var("BK_FILE").unwrap_or(String::from("data.json"));
|
||||
let mut_state = RwLock::new(AppState::new(state_size, &save_path));
|
||||
Arc::new(SharedState {
|
||||
static_state: StaticState {
|
||||
jwt_key: serde_json::from_str(
|
||||
dotenvy::var("JWT_SECRET")
|
||||
.unwrap_or(String::from("\"secret\""))
|
||||
.as_ref(),
|
||||
)
|
||||
.expect("an JSON string"),
|
||||
change_channel: broadcast::Sender::new(512),
|
||||
save_path,
|
||||
},
|
||||
mut_state,
|
||||
})
|
||||
}
|
72
backend/src/route.rs
Normal file
72
backend/src/route.rs
Normal file
|
@ -0,0 +1,72 @@
|
|||
use crate::authorization::jwt_middleware;
|
||||
use crate::handler;
|
||||
use crate::model;
|
||||
use axum::{handler::Handler, middleware};
|
||||
use axum::{routing::get, Router};
|
||||
use serde_json::to_writer;
|
||||
use std::fs::File;
|
||||
use tokio::task;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
pub fn create_router() -> Router {
|
||||
let db = model::make_db();
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
// allow requests from any origin
|
||||
.allow_origin(Any)
|
||||
.allow_headers(Any);
|
||||
let db_to_save = db.clone();
|
||||
task::spawn(async move {
|
||||
loop {
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
{
|
||||
let save_path = &db_to_save.static_state.save_path;
|
||||
let file = File::create(save_path);
|
||||
match file {
|
||||
Ok(f) => {
|
||||
let db = db_to_save.mut_state.read().await;
|
||||
match to_writer(f, &db.dmx) {
|
||||
Ok(()) => tracing::trace!("Saved data at {save_path}"),
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to save data: {e:?}");
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to save data: {e:?}");
|
||||
()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Router::new()
|
||||
.route("/api/healthcheck", get(handler::healthcheck_handler))
|
||||
.route(
|
||||
"/api/values",
|
||||
get(handler::list_values_handler).post(
|
||||
handler::batch_edit_value_handler
|
||||
.layer(middleware::from_fn_with_state(db.clone(), jwt_middleware)),
|
||||
),
|
||||
)
|
||||
.route("/api/sse", get(handler::sse_handler))
|
||||
.route(
|
||||
"/api/values/:id",
|
||||
get(handler::get_value_handler).post(
|
||||
handler::edit_value_handler
|
||||
.layer(middleware::from_fn_with_state(db.clone(), jwt_middleware)),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/motor",
|
||||
get(handler::get_motor_value_handler).post(
|
||||
handler::edit_motor_value_handler
|
||||
.layer(middleware::from_fn_with_state(db.clone(), jwt_middleware)),
|
||||
),
|
||||
)
|
||||
.layer(cors)
|
||||
.with_state(db)
|
||||
}
|
0
frontend/frontend/__init__.py
Normal file
0
frontend/frontend/__init__.py
Normal file
3
frontend/frontend/admin.py
Normal file
3
frontend/frontend/admin.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
6
frontend/frontend/apps.py
Normal file
6
frontend/frontend/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FrontendConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "frontend"
|
3
frontend/frontend/models.py
Normal file
3
frontend/frontend/models.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
116
frontend/frontend/static/js/update-color.js
Normal file
116
frontend/frontend/static/js/update-color.js
Normal file
|
@ -0,0 +1,116 @@
|
|||
function hexToRgb(hex) {
|
||||
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function componentToHex(c) {
|
||||
var hex = c.toString(16);
|
||||
return hex.length == 1 ? "0" + hex : hex;
|
||||
}
|
||||
|
||||
function rgbToHex({red, green, blue}) {
|
||||
return "#" + componentToHex(red) + componentToHex(green) + componentToHex(blue);
|
||||
}
|
||||
|
||||
let startSocket = () => {
|
||||
const socket = new EventSource(`${WEBSOCKET_ENDPOINT}/sse`);
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
const color = rgbToHex(message.value);
|
||||
console.log(color, `light${message.address}`);
|
||||
const element = document.getElementById(`light${message.address}`);
|
||||
if(element !== null) {
|
||||
element.style.fill = color;
|
||||
}
|
||||
const inputElement = document.getElementById(`input-light${message.address}`);
|
||||
if(inputElement !== null) {
|
||||
inputElement.value = color;
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("error", (event) => {
|
||||
console.error(event);
|
||||
startSocket();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', startSocket);
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Functions to open and close a modal
|
||||
function openModal($el) {
|
||||
$el.showModal();
|
||||
}
|
||||
|
||||
function closeModal($el) {
|
||||
$el.close();
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
(document.querySelectorAll('dialog') || []).forEach(($modal) => {
|
||||
closeModal($modal);
|
||||
});
|
||||
}
|
||||
|
||||
const $loadingModal = document.getElementById("modal-loading");
|
||||
closeModal($loadingModal);
|
||||
|
||||
// Add a click event on buttons to open a specific modal
|
||||
(document.querySelectorAll('.modal-trigger') || []).forEach(($trigger) => {
|
||||
const modal = $trigger.dataset.target;
|
||||
const $target = document.getElementById(modal);
|
||||
|
||||
if ($target !== null) $trigger.addEventListener('click', () => {
|
||||
openModal($target);
|
||||
});
|
||||
});
|
||||
|
||||
// Add a click event on various child elements to close the parent modal
|
||||
(document.querySelectorAll('.close-modal') || []).forEach(($close) => {
|
||||
const $target = $close.closest('dialog');
|
||||
|
||||
$close.addEventListener('click', () => {
|
||||
closeModal($target);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Add a click event to save colors
|
||||
(document.querySelectorAll('.save-color') || []).forEach(($trigger) => {
|
||||
const lightId = $trigger.dataset.target;
|
||||
const $input = document.getElementById(`input-light${lightId}`);
|
||||
const url = `${WEBSOCKET_ENDPOINT}/values/${lightId}`;
|
||||
|
||||
$trigger.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const color = hexToRgb($input.value);
|
||||
openModal($loadingModal)
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
//mode: "no-cors",
|
||||
headers: {
|
||||
'Accept': "application/json",
|
||||
'Content-Type': "application/json",
|
||||
'Authorization': `Bearer ${JWT}`
|
||||
},
|
||||
body: JSON.stringify({"red": color.r, "green": color.g, "blue": color.b}),
|
||||
}).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
alert(`Request failed. Err code:${resp.status}`);
|
||||
}
|
||||
}).catch((e) => {
|
||||
alert(`Request failed. Err: ${e}`);
|
||||
console.log(e);
|
||||
}).finally(() => {
|
||||
closeAllModals();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
BIN
frontend/frontend/static/media/logo.png
Normal file
BIN
frontend/frontend/static/media/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
1
frontend/frontend/static/media/user.svg
Normal file
1
frontend/frontend/static/media/user.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"/></svg>
|
After Width: | Height: | Size: 410 B |
60
frontend/frontend/templates/frontend/base.html
Normal file
60
frontend/frontend/templates/frontend/base.html
Normal file
|
@ -0,0 +1,60 @@
|
|||
{% load static %}
|
||||
<!doctype html>
|
||||
<html data-theme="light" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="stylesheet" href="{% static "css/pico.pumpkin.min.css" %}">
|
||||
<title>R/AGB</title>
|
||||
<link rel="shortcut icon" type="image/png" href="{% static 'media/logo.png' %}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="container">
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
|
||||
<a aria-label="Page d'accueil" href="{% url "frontend:home" %}">
|
||||
<img style="height:50px;" src="{% static "media/logo.png" %}" alt="Logo d'hackens"/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li class="hide-before-sm">
|
||||
<a class="contrast" href="{% url "frontend:home" %}">Accueil</a>
|
||||
</li>
|
||||
<li class="hide-before-sm">
|
||||
<a class="contrast" href="/api-docs/">API</a>
|
||||
</li>
|
||||
{% if user.is_staff %}
|
||||
<li class="hide-before-sm">
|
||||
<a class="contrast" href="{% url "admin:index" %}">Admin</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
<li>
|
||||
<a href="{% url "authens:logout" %}">
|
||||
<strong>Déconnexion</strong>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button>
|
||||
🧑 {{ user.username }}
|
||||
</button>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="{% url "authens:login" %}">
|
||||
Connexion
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
63
frontend/frontend/templates/frontend/blinder.html
Normal file
63
frontend/frontend/templates/frontend/blinder.html
Normal file
|
@ -0,0 +1,63 @@
|
|||
{% extends "frontend/base.html" %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
{% if not user.is_authenticated %}
|
||||
<article>
|
||||
<p>
|
||||
Connectez-vous pour pouvoir modifier des couleurs
|
||||
</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h1>{{ light_name }}</h1>
|
||||
<figure>
|
||||
<svg viewBox="-3.5 -3.5 112 112" version="1.1" id="blinder" >
|
||||
<style>
|
||||
circle {
|
||||
fill: white;
|
||||
stroke: black;
|
||||
stroke-width: 2;
|
||||
}
|
||||
{% if user.is_authenticated %}
|
||||
circle:hover {
|
||||
stroke: grey;
|
||||
}
|
||||
{% endif %}
|
||||
</style>
|
||||
<g id="layer1">
|
||||
<rect style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none" width="110" height="110" x="-2.5" y="-2.5" rx="15" />
|
||||
{% for light in lights %}
|
||||
<circle id="light{{ light.id }}" r="10" cx="{{ light.position_x }}" cy="{{ light.position_y }}" data-target="modal-light{{ light.id }}" class="modal-trigger"/>
|
||||
{% endfor %}
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
</section>
|
||||
{% if user.is_authenticated %}
|
||||
{% for light in lights %}
|
||||
<dialog id="modal-light{{light.id}}">
|
||||
<article>
|
||||
<header>
|
||||
<button class="close-modal" rel="prev" aria-label="close"></button>
|
||||
<h3>Couleur du pixel n°{{light.id}}</h3>
|
||||
</header>
|
||||
<section>
|
||||
<form>
|
||||
<input id="input-light{{light.id}}" class="input" type="color" name="light{{light.id}}">
|
||||
<button class="save-color" data-target="{{light.id}}">Enregistrer</button>
|
||||
</form>
|
||||
</section>
|
||||
</article>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<dialog open id="modal-loading">
|
||||
<article aria-busy="true"></article>
|
||||
</dialog>
|
||||
<script>const WEBSOCKET_ENDPOINT = "{{ websocket_endpoint }}";</script>
|
||||
{% if user.is_authenticated %}
|
||||
<script>const JWT = "{{ jwt }}";</script>
|
||||
{% endif %}
|
||||
<script src="{% static "js/update-color.js" %}" defer></script>
|
||||
{% endblock %}
|
2096
frontend/frontend/templates/frontend/home.html
Normal file
2096
frontend/frontend/templates/frontend/home.html
Normal file
File diff suppressed because one or more lines are too long
54
frontend/frontend/templates/frontend/led_tub.html
Normal file
54
frontend/frontend/templates/frontend/led_tub.html
Normal file
|
@ -0,0 +1,54 @@
|
|||
{% extends "frontend/base.html" %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
|
||||
<section class="section content container is-max-desktop">
|
||||
<h1 class="title is-3">{{ object.name }}</h1>
|
||||
<svg viewBox="-1 -1 162 12" version="1.1" id="barreled" >
|
||||
<style>
|
||||
rect {
|
||||
fill: white;
|
||||
stroke: black;
|
||||
stroke-width: 1;
|
||||
}
|
||||
{% if user.is_authenticated %}
|
||||
rect:hover {
|
||||
stroke: grey;
|
||||
}
|
||||
{% endif %}
|
||||
</style>
|
||||
<g id="layer1">
|
||||
{% for light in lights %}
|
||||
<rect id="light{{ light.id }}" width="10" height="10" x="{{ light.position }}" y="0" class="modal-trigger" data-target="modal-light{{ light.id }}" />
|
||||
{% endfor %}
|
||||
</g>
|
||||
</svg>
|
||||
</section>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
{% for light in lights %}
|
||||
<dialog id="modal-light{{light.id}}">
|
||||
<article>
|
||||
<header>
|
||||
<button class="close-modal" rel="prev" aria-label="close"></button>
|
||||
<h3>Couleur du pixel n°{{light.id}}</h3>
|
||||
</header>
|
||||
<section>
|
||||
<form>
|
||||
<input id="input-light{{light.id}}" class="input" type="color" name="light{{light.id}}">
|
||||
<button class="save-color" data-target="{{light.id}}">Enregistrer</button>
|
||||
</form>
|
||||
</section>
|
||||
</article>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<dialog open id="modal-loading">
|
||||
<article aria-busy="true"></article>
|
||||
</dialog>
|
||||
<script>const WEBSOCKET_ENDPOINT = "{{ websocket_endpoint }}";</script>
|
||||
{% if user.is_authenticated %}
|
||||
<script>const JWT = "{{ jwt }}";</script>
|
||||
{% endif %}
|
||||
<script src="{% static "js/update-color.js" %}" defer></script>
|
||||
{% endblock %}
|
29
frontend/frontend/templates/frontend/spot.html
Normal file
29
frontend/frontend/templates/frontend/spot.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
{% extends "frontend/base.html" %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
{% if not user.is_authenticated %}
|
||||
<article>
|
||||
<p>
|
||||
Connectez-vous pour pouvoir modifier des couleurs
|
||||
</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h1>{{ light_name }}</h1>
|
||||
{% if user.is_authenticated %}
|
||||
<form>
|
||||
<input id="input-light{{lights.id}}" class="input" type="color" name="light{{lights.id}}">
|
||||
<button class="save-color" data-target="{{lights.id}}">Enregistrer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
<dialog open id="modal-loading">
|
||||
<article aria-busy="true"></article>
|
||||
</dialog>
|
||||
<script>const WEBSOCKET_ENDPOINT = "{{ websocket_endpoint }}";</script>
|
||||
{% if user.is_authenticated %}
|
||||
<script>const JWT = "{{ jwt }}";</script>
|
||||
{% endif %}
|
||||
<script src="{% static "js/update-color.js" %}" defer></script>
|
||||
{% endblock %}
|
3
frontend/frontend/tests.py
Normal file
3
frontend/frontend/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
10
frontend/frontend/urls.py
Normal file
10
frontend/frontend/urls.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import HomeView, LightView, TokenView
|
||||
|
||||
app_name = "frontend"
|
||||
urlpatterns = [
|
||||
path("", HomeView.as_view(), name="home"),
|
||||
path("light/<str:light>/", LightView.as_view()),
|
||||
path("token", TokenView.as_view()),
|
||||
]
|
96
frontend/frontend/views.py
Normal file
96
frontend/frontend/views.py
Normal file
|
@ -0,0 +1,96 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import ViewDoesNotExist
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.views import View
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
|
||||
def get_context_from_proj(kind, chans):
|
||||
print(kind, chans)
|
||||
match kind:
|
||||
case "blinder":
|
||||
return [
|
||||
{
|
||||
"id": chans[i],
|
||||
"position_y": ((15 - i) // 4) * 25 + 15,
|
||||
"position_x": ((15 - i) % 4) * 25 + 15,
|
||||
}
|
||||
for i in range(len(chans))
|
||||
]
|
||||
case "led_tub":
|
||||
return [
|
||||
{
|
||||
"id": chans[i],
|
||||
"position": i * 10,
|
||||
}
|
||||
for i in range(len(chans))
|
||||
]
|
||||
case "spot":
|
||||
return {
|
||||
"id": chans[0],
|
||||
}
|
||||
|
||||
case _:
|
||||
raise ViewDoesNotExist()
|
||||
|
||||
|
||||
class TokenView(LoginRequiredMixin, View):
|
||||
def get(self, request, *arg, **kwargs):
|
||||
return JsonResponse(
|
||||
{
|
||||
"token": jwt.encode(
|
||||
{
|
||||
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=9),
|
||||
"sub": "ragb",
|
||||
"user": self.request.user.username,
|
||||
"is_cof": self.requests.user.groups.filter(name="cof").exists(),
|
||||
"scope": "modify",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LightView(TemplateView):
|
||||
def get_template_names(self):
|
||||
lights = settings.LIGHTS["lights"][self.kwargs["light"]]
|
||||
return [f"frontend/{lights['kind']}.html"]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.request.user.is_authenticated:
|
||||
context["jwt"] = jwt.encode(
|
||||
{
|
||||
"exp": datetime.now(tz=timezone.utc) + timedelta(hours=9),
|
||||
"sub": "ragb",
|
||||
"user": self.request.user.username,
|
||||
"scope": "modify",
|
||||
},
|
||||
settings.JWT_SECRET,
|
||||
)
|
||||
context["websocket_endpoint"] = settings.WEBSOCKET_ENDPOINT
|
||||
light = self.kwargs["light"]
|
||||
if light not in settings.LIGHTS["lights"]:
|
||||
raise Http404("Light does not exist")
|
||||
lights = settings.LIGHTS["lights"][light]
|
||||
context["lights"] = get_context_from_proj(lights["kind"], lights["channels"])
|
||||
context["light_name"] = lights["name"]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class HomeView(TemplateView):
|
||||
template_name = "frontend/home.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["websocket_endpoint"] = settings.WEBSOCKET_ENDPOINT
|
||||
lights = settings.LIGHTS["lights"]
|
||||
context["lights"] = lights
|
||||
|
||||
return context
|
22
frontend/manage.py
Executable file
22
frontend/manage.py
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ragb.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
49
frontend/patch.json
Normal file
49
frontend/patch.json
Normal file
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"lights": {
|
||||
"blinder": {
|
||||
"name": "Blinder",
|
||||
"kind": "blinder",
|
||||
"channels": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
},
|
||||
"screen_left": {
|
||||
"name": "Ecran-gauche",
|
||||
"kind": "spot",
|
||||
"channels": [ 16 ]
|
||||
},
|
||||
"screen_right": {
|
||||
"name": "Ecran-droite",
|
||||
"kind": "spot",
|
||||
"channels": [ 17 ]
|
||||
},
|
||||
"wall_left": {
|
||||
"name": "Mur gauche",
|
||||
"kind": "spot",
|
||||
"channels": [ 34 ]
|
||||
},
|
||||
"wall_right": {
|
||||
"name": "Mur droite",
|
||||
"kind": "spot",
|
||||
"channels": [ 35 ]
|
||||
},
|
||||
"barre_led_0": {
|
||||
"name": "Barre led 1",
|
||||
"kind": "led_tub",
|
||||
"channels" : [18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33]
|
||||
},
|
||||
"barre_led_1": {
|
||||
"name": "Barre led 2",
|
||||
"kind": "led_tub",
|
||||
"channels" : [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51]
|
||||
},
|
||||
"barre_led_2": {
|
||||
"name": "Barre led 3",
|
||||
"kind": "led_tub",
|
||||
"channels" : [52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67]
|
||||
},
|
||||
"barre_led_3": {
|
||||
"name": "Barre led 4",
|
||||
"kind": "led_tub",
|
||||
"channels" : [68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83]
|
||||
}
|
||||
}
|
||||
}
|
0
frontend/ragb/__init__.py
Normal file
0
frontend/ragb/__init__.py
Normal file
16
frontend/ragb/asgi.py
Normal file
16
frontend/ragb/asgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for ragb project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ragb.settings")
|
||||
|
||||
application = get_asgi_application()
|
196
frontend/ragb/settings.py
Normal file
196
frontend/ragb/settings.py
Normal file
|
@ -0,0 +1,196 @@
|
|||
"""
|
||||
Django settings for ragb project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.2.5.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-elz+_!5$ad6r%%_aia_8pdzdrd(i=d_krcd@gv1t11wolz@bv^"
|
||||
|
||||
JWT_SECRET = "secret"
|
||||
|
||||
DEV_KEY = "supersecret"
|
||||
|
||||
# Custom settings
|
||||
|
||||
WEBSOCKET_PORT = 9999
|
||||
WEBSOCKET_HOST = "127.0.0.1"
|
||||
WEBSOCKET_ENDPOINT = f"http://{WEBSOCKET_HOST}:{WEBSOCKET_PORT}/api"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = "127.0.0.1,localhost".split(",")
|
||||
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
]
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"authens",
|
||||
"frontend",
|
||||
"shared",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "ragb.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "ragb.wsgi.application"
|
||||
|
||||
# AuthENS
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"authens.backends.ENSCASBackend",
|
||||
]
|
||||
|
||||
AUTHENS_ALLOW_STAFF = True
|
||||
AUTHENS_USE_OLDCAS = False
|
||||
|
||||
|
||||
LOGIN_URL = reverse_lazy("authens:login")
|
||||
|
||||
LOGIN_REDIRECT_URL = reverse_lazy("frontend:home")
|
||||
LOGOUT_REDIRECT_URL = reverse_lazy("frontend:home")
|
||||
|
||||
# DJDT
|
||||
|
||||
|
||||
# DEBUG_TOOLBAR_CONFIG = {
|
||||
# # Toolbar options
|
||||
# "PROFILER_MAX_DEPTH": 20,
|
||||
# "SHOW_TOOLBAR_CALLBACK": "ragb.utils.show_debug_toolbar",
|
||||
# }
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
"HOST": "",
|
||||
"PORT": "",
|
||||
},
|
||||
}
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = (
|
||||
[
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
if not DEBUG
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# logging
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "INFO",
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"django": {
|
||||
"handlers": ["console"],
|
||||
"propagate": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
STATIC_ROOT = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LIGHTS = json.load(open(BASE_DIR / "patch.json"))
|
26
frontend/ragb/urls.py
Normal file
26
frontend/ragb/urls.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
URL configuration for ragb project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/4.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("authens/", include("authens.urls")),
|
||||
path("", include("frontend.urls")),
|
||||
]
|
16
frontend/ragb/wsgi.py
Normal file
16
frontend/ragb/wsgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for ragb project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ragb.settings")
|
||||
|
||||
application = get_wsgi_application()
|
0
frontend/shared/__init__.py
Normal file
0
frontend/shared/__init__.py
Normal file
6
frontend/shared/apps.py
Normal file
6
frontend/shared/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SharedConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'shared'
|
4
frontend/shared/static/css/pico.amber.min.css
vendored
Normal file
4
frontend/shared/static/css/pico.amber.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/shared/static/css/pico.green.min.css
vendored
Normal file
4
frontend/shared/static/css/pico.green.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/shared/static/css/pico.pumpkin.min.css
vendored
Normal file
4
frontend/shared/static/css/pico.pumpkin.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
80
npins/default.nix
Normal file
80
npins/default.nix
Normal file
|
@ -0,0 +1,80 @@
|
|||
# Generated by npins. Do not modify; will be overwritten regularly
|
||||
let
|
||||
data = builtins.fromJSON (builtins.readFile ./sources.json);
|
||||
version = data.version;
|
||||
|
||||
mkSource =
|
||||
spec:
|
||||
assert spec ? type;
|
||||
let
|
||||
path =
|
||||
if spec.type == "Git" then
|
||||
mkGitSource spec
|
||||
else if spec.type == "GitRelease" then
|
||||
mkGitSource spec
|
||||
else if spec.type == "PyPi" then
|
||||
mkPyPiSource spec
|
||||
else if spec.type == "Channel" then
|
||||
mkChannelSource spec
|
||||
else
|
||||
builtins.throw "Unknown source type ${spec.type}";
|
||||
in
|
||||
spec // { outPath = path; };
|
||||
|
||||
mkGitSource =
|
||||
{
|
||||
repository,
|
||||
revision,
|
||||
url ? null,
|
||||
hash,
|
||||
branch ? null,
|
||||
...
|
||||
}:
|
||||
assert repository ? type;
|
||||
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
|
||||
# In the latter case, there we will always be an url to the tarball
|
||||
if url != null then
|
||||
(builtins.fetchTarball {
|
||||
inherit url;
|
||||
sha256 = hash; # FIXME: check nix version & use SRI hashes
|
||||
})
|
||||
else
|
||||
assert repository.type == "Git";
|
||||
let
|
||||
urlToName =
|
||||
url: rev:
|
||||
let
|
||||
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
|
||||
|
||||
short = builtins.substring 0 7 rev;
|
||||
|
||||
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
|
||||
in
|
||||
"${if matched == null then "source" else builtins.head matched}${appendShort}";
|
||||
name = urlToName repository.url revision;
|
||||
in
|
||||
builtins.fetchGit {
|
||||
url = repository.url;
|
||||
rev = revision;
|
||||
inherit name;
|
||||
# hash = hash;
|
||||
};
|
||||
|
||||
mkPyPiSource =
|
||||
{ url, hash, ... }:
|
||||
builtins.fetchurl {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
};
|
||||
|
||||
mkChannelSource =
|
||||
{ url, hash, ... }:
|
||||
builtins.fetchTarball {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
};
|
||||
in
|
||||
if version == 3 then
|
||||
builtins.mapAttrs (_: mkSource) data.pins
|
||||
else
|
||||
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"
|
11
npins/sources.json
Normal file
11
npins/sources.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"pins": {
|
||||
"nixpkgs": {
|
||||
"type": "Channel",
|
||||
"name": "nixpkgs-unstable",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre690413.1366d1af8f58/nixexprs.tar.xz",
|
||||
"hash": "0nlrrs22pf3dr7m55glxxfdg2qn6q9sjsd5dc7bvigasb1v3gw4j"
|
||||
}
|
||||
},
|
||||
"version": 3
|
||||
}
|
12
provisioning/authens.nix
Normal file
12
provisioning/authens.nix
Normal file
|
@ -0,0 +1,12 @@
|
|||
{ lib, fetchgit, pythoncas, django, ldap, buildPythonPackage }:
|
||||
buildPythonPackage rec {
|
||||
pname = "authens";
|
||||
version = "v0.1b5";
|
||||
doCheck = false;
|
||||
src = fetchgit {
|
||||
url = "https://git.eleves.ens.fr/klub-dev-ens/authens.git";
|
||||
rev = "58747e57b30b47f36a0ed3e7c80850ed7f1edbf9";
|
||||
hash = "sha256-R0Nw212/BOPHfpspT5wzxtji1vxZ/JOuwr00naklWE8=";
|
||||
};
|
||||
propagatedBuildInputs = [ django ldap pythoncas ];
|
||||
}
|
13
provisioning/python-cas.nix
Normal file
13
provisioning/python-cas.nix
Normal file
|
@ -0,0 +1,13 @@
|
|||
{ lib, requests, lxml, six, buildPythonPackage, fetchFromGitHub }:
|
||||
buildPythonPackage rec {
|
||||
pname = "python-cas";
|
||||
version = "1.6.0";
|
||||
doCheck = false;
|
||||
src = fetchFromGitHub {
|
||||
owner = "python-cas";
|
||||
repo = "python-cas";
|
||||
rev = "v1.6.0";
|
||||
sha512 = "sha512-qnYzgwELUij2EdqA6H17q8vnNUsfI7DkbZSI8CCIGfXOM+cZ7vsWe7CJxzsDUw73sBPB4+zzpLxvb7tpm/IDeg==";
|
||||
};
|
||||
propagatedBuildInputs = [ requests lxml six ];
|
||||
}
|
20
provisioning/python.nix
Normal file
20
provisioning/python.nix
Normal file
|
@ -0,0 +1,20 @@
|
|||
{ lib, python3, debug ? false }:
|
||||
let
|
||||
python = python3.override {
|
||||
packageOverrides = self: super: {
|
||||
django = super.django_4;
|
||||
authens = self.callPackage ./authens.nix { };
|
||||
pythoncas = self.callPackage ./python-cas.nix { };
|
||||
};
|
||||
};
|
||||
in
|
||||
python.withPackages (ps: [
|
||||
ps.django
|
||||
ps.gunicorn
|
||||
ps.authens
|
||||
ps.pyjwt
|
||||
] ++ lib.optionals debug [
|
||||
ps.django-debug-toolbar
|
||||
ps.black
|
||||
ps.isort
|
||||
])
|
85
provisioning/script.py
Normal file
85
provisioning/script.py
Normal file
|
@ -0,0 +1,85 @@
|
|||
#!/nix/store/z46wvcd15152qnsry80p8ricxya2n2lr-python3-3.11.7-env/bin/python
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from colour import Color
|
||||
from pyjecteur.fixtures import Blinder, LedBar48Ch, Tradi
|
||||
from pyjecteur.lights import Universe
|
||||
from pyjecteur.widget import Widget
|
||||
|
||||
if False: # True:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
else:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
w = Widget("/dev/ttyUSB0")
|
||||
|
||||
DIM = {
|
||||
"blinder": 0.10,
|
||||
"led_tub": 0.05,
|
||||
"spot": 0.3,
|
||||
}
|
||||
|
||||
u = Universe(w)
|
||||
|
||||
|
||||
def strToProj(s):
|
||||
match s:
|
||||
case "spot":
|
||||
return Tradi()
|
||||
case "led_tub":
|
||||
return LedBar48Ch()
|
||||
case "blinder":
|
||||
return Blinder()
|
||||
|
||||
|
||||
r = requests.get("https://agb.hackens.org/api-docs/patch.json")
|
||||
patch = r.json()
|
||||
|
||||
lights = {}
|
||||
|
||||
update = {}
|
||||
|
||||
current_addr = 0
|
||||
|
||||
for k, v in patch["lights"].items():
|
||||
lights[k] = strToProj(v["kind"])
|
||||
u.register(lights[k], current_addr)
|
||||
# update dmx since some params are set before
|
||||
lights[k].update_dmx()
|
||||
logging.info(
|
||||
f"Light {k} of kind {v['kind']} is at DMX{current_addr+1} (PLS convention)"
|
||||
)
|
||||
for i, chan in enumerate(v["channels"]):
|
||||
update[chan] = (k, i) # put the light name
|
||||
current_addr += lights[k].address_size
|
||||
|
||||
|
||||
def update_light(address, red, green, blue):
|
||||
light, chan = update[address]
|
||||
kind = patch["lights"][light]["kind"]
|
||||
r, g, b = red * DIM[kind]/255, (green * DIM[kind])/255, (blue * DIM[kind])/255
|
||||
match kind:
|
||||
case "blinder":
|
||||
lights[light].colors[chan] = Color(rgb=(r, g, b))
|
||||
case "led_tub":
|
||||
lights[light].colors[chan] = Color(rgb=(r, g, b))
|
||||
case "spot":
|
||||
lights[light].color = Color(rgb=(r, g, b))
|
||||
|
||||
|
||||
def run():
|
||||
logging.info("Started")
|
||||
for line in sys.stdin:
|
||||
logging.debug(line)
|
||||
if line.startswith("data:"):
|
||||
dataStr = line[5:]
|
||||
logging.debug(f"Received: {dataStr}")
|
||||
data = json.loads(dataStr)
|
||||
if data["address"] in update:
|
||||
update_light(data["address"], **data["value"])
|
||||
|
||||
run()
|
||||
|
11
provisioning/shell.nix
Normal file
11
provisioning/shell.nix
Normal file
|
@ -0,0 +1,11 @@
|
|||
{ pkgs ? import (import ../npins).nixpkgs { } }:
|
||||
pkgs.mkShell {
|
||||
buildInputs = [
|
||||
(pkgs.callPackage ./python.nix { debug = true; })
|
||||
pkgs.cargo
|
||||
pkgs.cargo-edit
|
||||
pkgs.rustc
|
||||
pkgs.rust-analyzer
|
||||
pkgs.rustfmt
|
||||
];
|
||||
}
|
1
shell.nix
Symbolic link
1
shell.nix
Symbolic link
|
@ -0,0 +1 @@
|
|||
provisioning/shell.nix
|
Loading…
Reference in a new issue