generated from esd2-groupwork/template-repository
Compare commits
37 commits
Author | SHA1 | Date | |
---|---|---|---|
fd07adbb75 | |||
abb5263763 | |||
c5c62c5dbc | |||
57c514a289 | |||
0dff50269f | |||
57957e9285 | |||
1c3f60196c | |||
69fb508486 | |||
7f34d56ba3 | |||
6a78a80e85 | |||
0f03ec54ff | |||
4d4af9cf19 | |||
7197b71f0a | |||
eefaa646b1 | |||
572734ff0d | |||
445cf844c8 | |||
cdbe8e4659 | |||
67be087183 | |||
c72be40454 | |||
34e6699adf | |||
5bf7745ca6 | |||
2738513f3e | |||
a68d4efebc | |||
f8d6645690 | |||
5b0172b2b9 | |||
c01fd63211 | |||
1d775a5e19 | |||
bb6b883c4b | |||
e1b4dcafde | |||
509685ff0e | |||
fbd0ac4ca0 | |||
2f09bfb0f6 | |||
eec82cdfb5 | |||
beb0cd25dd | |||
0b367c019f | |||
23276e94d2 | |||
6f4ddf5e00 |
31 changed files with 26965 additions and 5 deletions
1
.cargo/config.toml
Normal file
1
.cargo/config.toml
Normal file
|
@ -0,0 +1 @@
|
|||
[build]
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1 +1,3 @@
|
|||
/target
|
||||
target/
|
||||
logs/
|
||||
*.png
|
||||
|
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
[submodule "reference_material"]
|
||||
path = reference_material
|
||||
url = https://github.com/ravvenlabs/userspace-vdma-driver
|
1974
Cargo.lock
generated
1974
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
@ -6,3 +6,13 @@ edition = "2021"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.35"
|
||||
fern = "0.6.2"
|
||||
image = { version = "0.25.0", default-features = false, features = ["png","ff","gif"] }
|
||||
ipnet = "2.9.0"
|
||||
local-ip-address = "0.6.1"
|
||||
log = "0.4.21"
|
||||
rand = "0.8.5"
|
||||
rocket = "0.5.0"
|
||||
rocket_cors = "0.6.0"
|
||||
sudo = "0.6.0"
|
||||
|
|
12
README.md
12
README.md
|
@ -1,3 +1,13 @@
|
|||
# virtual-camera
|
||||
|
||||
Library and server for dissemination of images obtained from the world server.
|
||||
Library and server for dissemination of images obtained from the world server.
|
||||
|
||||
Currently uses TCP to stream images from the world server. Images are saved temporarily for testing purposes.
|
||||
|
||||
## Development
|
||||
|
||||
For development, you must first have Rust installed. This can be done by following the instructions found at [https://rustup.rs/](https://rustup.rs/). Once this is done, clone this repository, and in the root folder of the repository, run `cargo build --release`. This will both build the dependencies, install the correct toolchains, and create a final executable.
|
||||
|
||||
## Testing
|
||||
|
||||
To test this, first download the `comms-testing` branch of the World Server repository, open the `tennis` project, and run the project. Once this is done, build the latest version of this repository, as mentioned above. Finally, run this project, using `cargo run`.
|
||||
|
|
BIN
logScreeenshot.png
Normal file
BIN
logScreeenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 215 KiB |
7
logs/2024-03-21_14.47.log
Normal file
7
logs/2024-03-21_14.47.log
Normal file
|
@ -0,0 +1,7 @@
|
|||
2024-03-21T14:47:31.759962308-04:00 - [INFO, communication_layer] - aggregating all IPs... This may take several minutes...
|
||||
2024-03-21T14:47:36.825148581-04:00 - [INFO, communication_layer] - Stream connected to address: Ok(192.168.0.80:55001)
|
||||
2024-03-21T14:47:36.825292754-04:00 - [INFO, communication_layer] - Start reading...
|
||||
2024-03-21T14:47:38.021199638-04:00 - [INFO, communication_layer] - Image successfully recieved
|
||||
2024-03-21T14:47:38.021453459-04:00 - [INFO, communication_layer] - Building image...
|
||||
2024-03-21T14:47:39.127447553-04:00 - [INFO, communication_layer] - Image built, saving to file...
|
||||
2024-03-21T14:47:44.733286335-04:00 - [INFO, communication_layer] - image saved!
|
8
notes.md
8
notes.md
|
@ -1,5 +1,13 @@
|
|||
<<<<<<< HEAD
|
||||
Communication layer:
|
||||
Add image resolution to initial handshake
|
||||
unity: send x and y resolution
|
||||
SOC: send back colour bit depth
|
||||
unity: check bitdepth before beginning image transfer
|
||||
=======
|
||||
Rust Crate: `image-0.24.9`
|
||||
|
||||
Read image using `ImageBuffer::from_raw(w,h,bytes)`
|
||||
|
||||
image size: 4608 x 2592 x 3 [BGR]
|
||||
>>>>>>> main
|
||||
|
|
1
reference_material
Submodule
1
reference_material
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit cfbb72d5ffac8d253ac6653e268efc76f7d0a3d5
|
|
@ -1,5 +1,5 @@
|
|||
[toolchain]
|
||||
channel="1.76.0-2024-02-08"
|
||||
components = [ "rustc", "cargo", "rust-std", "rustfmt", "rust-docs", "rust-analyzer" ]
|
||||
targets = [ "armv7-unknown-linux-musleabi" ]
|
||||
targets = [ "armv7-unknown-linux-gnueabihf" ]
|
||||
profile = "default"
|
||||
|
|
1774
serve1.csv
Normal file
1774
serve1.csv
Normal file
File diff suppressed because it is too large
Load diff
1774
serve1.dat
Normal file
1774
serve1.dat
Normal file
File diff suppressed because it is too large
Load diff
1853
serve2.csv
Normal file
1853
serve2.csv
Normal file
File diff suppressed because it is too large
Load diff
1921
serve3.csv
Normal file
1921
serve3.csv
Normal file
File diff suppressed because it is too large
Load diff
2029
serve4.csv
Normal file
2029
serve4.csv
Normal file
File diff suppressed because it is too large
Load diff
1739
serve5.csv
Normal file
1739
serve5.csv
Normal file
File diff suppressed because it is too large
Load diff
264
src/cam_comm.rs
Normal file
264
src/cam_comm.rs
Normal file
|
@ -0,0 +1,264 @@
|
|||
use local_ip_address::local_ip;
|
||||
use std::{ io::{Bytes, stdin, BufReader, Error, ErrorKind, Read, Write}, net::{IpAddr, Ipv4Addr, SocketAddrV4, TcpStream}, thread::{self, JoinHandle}, time::Duration};
|
||||
use image::{Rgb, RgbImage};
|
||||
use ipnet::{Ipv4Net,PrefixLenError};
|
||||
use log::{trace,debug,warn,error,info};
|
||||
//use crate::vdma_facade::feedthrough::VdmaHandle;
|
||||
|
||||
///RIT owns the subnet 129.21.0.0/16; probing by attempting to open thousands of TCP streams
|
||||
///simultaneously is unwise.
|
||||
const UNSAFE_SUBNET:Result<Ipv4Net,PrefixLenError> = Ipv4Net::new(Ipv4Addr::new(129,21,0,0),16);
|
||||
|
||||
///This subnet is universally considered the "local" subnet. This results in 256 addresses, and can
|
||||
///be probed both safely and efficiently, and as long as the world server on the LAN is running it
|
||||
///is functionally guaranteeed to be found.
|
||||
const HOME_SUBNET:Result<Ipv4Net,PrefixLenError> = Ipv4Net::new(Ipv4Addr::new(192,168,0,0),24);
|
||||
|
||||
///Current communications takes place on custom port.
|
||||
///TODO: RTSP port is 554; prepare for real world situations
|
||||
const PORT_NUMBER:u16 = 55001;
|
||||
///Port probe timeout, for
|
||||
const PORT_PROBE_TIMEOUT:Duration = Duration::new(5,0);
|
||||
///The standard subnet is a /24
|
||||
const SUBNET_SIZE:u8 = 24;
|
||||
|
||||
///Storage of all world serve connection information
|
||||
pub struct WorldServerConnection{
|
||||
///Store all open TcpStreams
|
||||
port_list:Vec<(TcpStream,BufReader<TcpStream>)>,
|
||||
///Unique image identifier
|
||||
image_index:u64,
|
||||
//VDMA instance
|
||||
//vdma:Option<VdmaHandle>,
|
||||
}
|
||||
impl WorldServerConnection{
|
||||
pub fn new() -> Result<Self,Error>{
|
||||
let host_ip = local_ip().expect("No real IP address!");
|
||||
trace!("Host IP: {:?}",host_ip);
|
||||
let mut thread_list:Vec<JoinHandle<Result<TcpStream,Error>>> = Vec::new();
|
||||
let mut port_list:Vec<(TcpStream,BufReader<TcpStream>)> = Vec::new();
|
||||
if let IpAddr::V4(host_v4_ip) = host_ip{
|
||||
//Check current subnet
|
||||
let net = Ipv4Net::new(host_v4_ip,SUBNET_SIZE);
|
||||
|
||||
if let Ok(net) = net{
|
||||
//RIT owns 129.21.0.0/16; DO NOT SPAM THIS NETWORK
|
||||
if UNSAFE_SUBNET.unwrap().contains(&net){
|
||||
//Request world server address from user
|
||||
let address = manual_address_prompt();
|
||||
if address.is_ok(){
|
||||
let real_address = address.unwrap();
|
||||
let reader = BufReader::new(real_address.try_clone().expect("Failed clone"));
|
||||
port_list.push((real_address,reader));
|
||||
//thread_list won't be added to, so the thread for loop won't execute
|
||||
}
|
||||
} else if net.contains(&HOME_SUBNET.unwrap()){
|
||||
//Collect all Hosts on the current subnet
|
||||
info!("aggregating all IPs... This may take several minutes...");
|
||||
for net_address in net.hosts(){
|
||||
thread_list.push(
|
||||
thread::spawn(move ||{
|
||||
let ip_port = std::net::SocketAddr::V4(SocketAddrV4::new(net_address, PORT_NUMBER));
|
||||
//Attempt to open a TCP stream with this IP address, exit the thread
|
||||
//returning the open TcpStream, or an error
|
||||
return TcpStream::connect_timeout(&ip_port, PORT_PROBE_TIMEOUT);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Multithread the port-sniffing, only save sucessful connections
|
||||
for thread in thread_list{
|
||||
let output = thread.join().unwrap();
|
||||
if let Ok(real_output) = output{
|
||||
let reader = BufReader::new(real_output.try_clone().expect("Failed clone"));
|
||||
port_list.push((real_output,reader));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Self{port_list, image_index:0});//, vdma:None});
|
||||
}
|
||||
|
||||
|
||||
pub fn get_next_images(&mut self,index:u32){
|
||||
for (ref mut stream,ref mut reader) in self.port_list.iter_mut(){
|
||||
info!("Stream connected to address: {:?}",stream.peer_addr());
|
||||
send_processing_time(stream, index);
|
||||
let header_values = read_image_header(stream, reader, &mut self.image_index);
|
||||
//if self.vdma.is_none(){
|
||||
// self.vdma = Some(VdmaHandle::new(header_values.0, header_values.1, header_values.2).expect("Vdma init failed!"));
|
||||
//}
|
||||
read_image(stream, reader, header_values.clone());
|
||||
let header_values = read_image_header(stream, reader, &mut self.image_index);
|
||||
read_image(stream, reader, header_values.clone());
|
||||
self.image_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
///Borrows an iterator over the bytes in a buffered-reader of a TcpStream; reads the next 4 bytes,
|
||||
///outputs the generated u32
|
||||
fn bytes_to_u32(iter:&mut Bytes<&mut BufReader<TcpStream>>) -> u32{
|
||||
let mut array:[u8;4] = [0;4];
|
||||
for i in 0..4{
|
||||
array[i] = iter.next().unwrap().unwrap();
|
||||
}
|
||||
return u32::from_le_bytes(array);
|
||||
}
|
||||
|
||||
fn bytes_to_u64(iter:&mut Bytes<&mut BufReader<TcpStream>>) -> u64{
|
||||
let mut array:[u8;8] = [0;8];
|
||||
for i in 0..8{
|
||||
array[i] = iter.next().unwrap().unwrap();
|
||||
debug!("{:?}",array[i]);
|
||||
}
|
||||
return u64::from_le_bytes(array);
|
||||
}
|
||||
///Request the user to input the world server's address into stdin
|
||||
fn manual_address_prompt() -> Result<TcpStream,Error>{
|
||||
let return_val:Result<TcpStream,Error> = Err(Error::new(ErrorKind::AddrNotAvailable,"Invalid Address"));
|
||||
while return_val.is_err(){
|
||||
let mut user_input:String = String::default();
|
||||
match stdin().read_line(&mut user_input){
|
||||
Ok(_) => {
|
||||
match user_input.trim().parse::<SocketAddrV4>(){
|
||||
Ok(address) => {
|
||||
//Test if the address is accurate
|
||||
let stream = TcpStream::connect_timeout(&std::net::SocketAddr::V4(address), PORT_PROBE_TIMEOUT);
|
||||
if stream.is_ok(){
|
||||
return stream;
|
||||
} else {
|
||||
warn!("Address unavailable!");
|
||||
debug!("{:?}",stream.unwrap_err());
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
error!("Unable to properly parse user input!");
|
||||
debug!("{:?}",error);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
error!("Unable to read user input!");
|
||||
debug!("{:?}",error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return return_val;
|
||||
}
|
||||
|
||||
//Used to send the calculated time value to the world server
|
||||
fn send_processing_time(stream:&mut TcpStream ,sent_value:u32){
|
||||
debug!("Begin handshake; sending timestamp float...");
|
||||
//Write the f64 value, in bytes, to the stream, and ensure it has been sent
|
||||
stream.write(&sent_value.to_le_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
debug!("Timestamp sent!");
|
||||
}
|
||||
|
||||
//Used to recieve images from the world server
|
||||
fn read_image_header(stream:&mut TcpStream, reader:&mut BufReader<TcpStream>, image_index:&mut u64) -> (u32,u32,u32,u64,String){
|
||||
//Create an iterator over the stream's bytes
|
||||
let mut iter = reader.bytes();
|
||||
|
||||
debug!("Continue handshake, read back image packet length...");
|
||||
//The first values sent will always be a u32 containing the length of the message (without the
|
||||
//length counted), followed by the image width and height
|
||||
let length = bytes_to_u64(&mut iter);
|
||||
debug!("Image packet length read as {:?}!",length);
|
||||
|
||||
debug!("Continue handshake, read back image width...");
|
||||
let image_width = bytes_to_u32(&mut iter);
|
||||
debug!("Image width read as {:?}!",image_width);
|
||||
|
||||
debug!("Continue handshake, read back image height...");
|
||||
let image_height = bytes_to_u32(&mut iter);
|
||||
debug!("Image height read as {:?}!",image_height);
|
||||
|
||||
//The length of the image is the total number of bytes
|
||||
let byte_depth = (length / ((image_width * image_height) as u64)) as u32;
|
||||
debug!("Image bitdepth calculated as {:?}!", byte_depth);
|
||||
|
||||
//Confirm to world server the correct number of bytes
|
||||
stream.write(&byte_depth.to_le_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
|
||||
debug!("Reading camera source info...");
|
||||
let camera_source = bytes_to_u32(&mut iter);
|
||||
let camera_name:&str;
|
||||
let top_cam_img = "./Top".to_owned() + &image_index.to_string() + ".png";
|
||||
let side_cam_img = "./Side".to_owned() + &image_index.to_string() + ".png";
|
||||
if camera_source == u32::MIN{
|
||||
debug!("Image source defined as \"Top\"!");
|
||||
camera_name = &top_cam_img;
|
||||
} else if camera_source == u32::MAX {
|
||||
debug!("Image source defined as \"Side\"!");
|
||||
camera_name = &side_cam_img;
|
||||
} else {
|
||||
error!("Unknown camera value!!!");
|
||||
camera_name = "./unknown.png";
|
||||
}
|
||||
|
||||
debug!("Writing {:?} to stream...", camera_source.to_le_bytes());
|
||||
stream.write(&camera_source.to_le_bytes()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
(image_width,image_height,byte_depth,length,camera_name.to_string())
|
||||
}
|
||||
|
||||
fn read_image(stream:&mut TcpStream, reader:&mut BufReader<TcpStream>, header_info:(u32,u32,u32,u64,String)){
|
||||
let image_width = header_info.0;
|
||||
let image_height = header_info.1;
|
||||
let byte_depth = header_info.2;
|
||||
let image_length = header_info.3;
|
||||
let image_name = header_info.4;
|
||||
|
||||
//Create a storage point for the bytes
|
||||
let mut recieved_bytes:Vec<u8> = Vec::new();
|
||||
//Create an iterator over the stream's bytes
|
||||
let mut iter = reader.bytes();
|
||||
//TODO: Remove assumption
|
||||
//Always assumes world server will send image
|
||||
info!("Start reading...");
|
||||
//Recieve the rest of the message
|
||||
for _ in 0..image_length{
|
||||
recieved_bytes.push(iter.next().unwrap().unwrap_or(0));
|
||||
}
|
||||
|
||||
info!("Image successfully recieved");
|
||||
|
||||
//Create a blank pane on which to store the read in pixels
|
||||
let mut img = RgbImage::new(image_width,image_height);
|
||||
|
||||
//Create a temporary storage point for u8s when generating pixels
|
||||
let mut temp:Vec<u8> = Vec::new();
|
||||
|
||||
//Iterate over bytes
|
||||
info!("Building image...");
|
||||
for i in 0..recieved_bytes.len(){
|
||||
let byte = recieved_bytes.get(i).expect("Index does not exist!");
|
||||
|
||||
temp.push(byte.clone());
|
||||
//Each pixel is composed of [byte_depth] u8s
|
||||
if ((i as u32)+1) % byte_depth == 0{
|
||||
//Coordinate in the pane = i/3
|
||||
let pixel_number:u32 = ((i as u32) /byte_depth).try_into().unwrap();
|
||||
//Insert the pixel into the pane
|
||||
img.put_pixel(
|
||||
//Pixels coming directly from Unity are sent in reference to Unity; that is,
|
||||
//starting at the bottom left, and iterating horizontally, then vertically.
|
||||
//This line compensates and creates a properly-oriented image
|
||||
pixel_number%image_width, (image_height-1)-(pixel_number / image_width),
|
||||
//Extract image from temporary storage, ang generate pixel
|
||||
Rgb::<u8>{0:temp.clone().try_into().expect("bad vector length!")}
|
||||
);
|
||||
//Clear temporary storage
|
||||
temp.clear();
|
||||
}
|
||||
}
|
||||
//Dump image to file
|
||||
//ONLY FOR EXPERIMENTATION AND TESTING PURPOSES
|
||||
info!("Image built, saving to file...");
|
||||
_ = img.save(image_name);
|
||||
info!("image saved!");
|
||||
}
|
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
#[macro_use] extern crate rocket;
|
||||
pub mod cam_comm;
|
||||
pub mod rest_api;
|
||||
pub mod vdma_facade;
|
67
src/main.rs
67
src/main.rs
|
@ -1,3 +1,66 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod cam_comm;
|
||||
use std::{ fs, path::Path};
|
||||
use chrono::{DateTime, Local};
|
||||
use communication_layer::rest_api;
|
||||
use log::LevelFilter;
|
||||
use fern::{log_file, Dispatch};
|
||||
use sudo::escalate_if_needed;
|
||||
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() {
|
||||
setup_logs(&true);
|
||||
_ = escalate_if_needed();
|
||||
rest_api::start_rest_endpoint().await;
|
||||
}
|
||||
|
||||
|
||||
///Set up logging macros to be used throughout the program
|
||||
fn setup_logs(debug:&bool){
|
||||
//Get the current time
|
||||
let chrono_now: DateTime<Local> = Local::now();
|
||||
//Create a log directory, if it does not exist
|
||||
if ! Path::new("logs").is_dir(){
|
||||
_ = fs::create_dir("logs");
|
||||
};
|
||||
//Create a log macro listener
|
||||
_ = fern::Dispatch::new()
|
||||
//Format the output messages
|
||||
.format(|out,message,record|{
|
||||
out.finish(format_args!(
|
||||
"{} - [{}, {}] - {}",
|
||||
Local::now().to_rfc3339(),
|
||||
record.level(),
|
||||
record.target(),
|
||||
message
|
||||
))
|
||||
})
|
||||
//Output to the file
|
||||
.chain({
|
||||
let mut file_logger = Dispatch::new();
|
||||
let date_format = chrono_now.format("%Y-%m-%d_%H.%M").to_string();
|
||||
let local_log_file = log_file(format!("logs/{}.log",date_format)).unwrap();
|
||||
//Set filter based on whether the command is being run in "debug" mode
|
||||
if *debug{
|
||||
file_logger = file_logger.level(LevelFilter::Trace);
|
||||
}
|
||||
else {
|
||||
file_logger = file_logger.level(LevelFilter::Debug);
|
||||
}
|
||||
//Apply the filter and file formatting rules described above
|
||||
file_logger.chain(local_log_file)
|
||||
})
|
||||
.chain({
|
||||
//Also log to stdout
|
||||
let mut stdout_logger = fern::Dispatch::new();
|
||||
if *debug {
|
||||
stdout_logger = stdout_logger.level(LevelFilter::Debug);
|
||||
}
|
||||
else {
|
||||
stdout_logger = stdout_logger.level(LevelFilter::Info);
|
||||
}
|
||||
stdout_logger.chain(std::io::stdout())
|
||||
})
|
||||
//Start listening to macros
|
||||
.apply();
|
||||
}
|
||||
|
|
57
src/rest_api.rs
Normal file
57
src/rest_api.rs
Normal file
|
@ -0,0 +1,57 @@
|
|||
use std::{path::{Path, PathBuf}, sync::Mutex};
|
||||
use crate::cam_comm::WorldServerConnection;
|
||||
use local_ip_address::local_ip;
|
||||
use rocket::{figment::Figment, fs::NamedFile, http::Method, response::status, Config, State};
|
||||
use rocket_cors::{AllowedOrigins, CorsOptions};
|
||||
|
||||
#[post("/serve", data = "<raw_index>")]
|
||||
fn start_serve(raw_index:String,/* cams:&State<Mutex<WorldServerConnection>>*/) -> status::Accepted<()> {
|
||||
println!("{:?}",raw_index);
|
||||
//let mut lock = cams.lock().unwrap();
|
||||
//lock.get_next_images(index);
|
||||
status::Accepted(())
|
||||
}
|
||||
|
||||
//Get most recently updated file
|
||||
#[get("/serve")]
|
||||
async fn get_serve_data() -> Option<NamedFile>{
|
||||
let last_modified_file = std::fs::read_dir(".") //Read in the current directory
|
||||
.expect("couldn't access local dir") //assume the directory is real
|
||||
.flatten() //Ignore all the files that have permissions issues
|
||||
.filter(|file| file.metadata().unwrap().is_file()) //Ignore files that aren't actually files
|
||||
.max_by_key(|x| x.metadata().unwrap().modified().unwrap()) //Find newest file
|
||||
.unwrap(); //Assume the newest file actually exists
|
||||
NamedFile::open(last_modified_file.path()).await.ok()
|
||||
}
|
||||
|
||||
///Get specific file, as defined by request
|
||||
#[deprecated(since = "TBD")]
|
||||
#[get("/serve/<file..>")]
|
||||
async fn get_data(file:PathBuf) -> Option<NamedFile>{
|
||||
NamedFile::open(file).await.ok()
|
||||
}
|
||||
|
||||
///Initialises the world server connection, spins Rocket into a unique process
|
||||
pub async fn start_rest_endpoint(){
|
||||
//Rocket defaults to using the localhost loopback address. This isn't ideal for prod usage, so
|
||||
//instead we bind to the host IP address.
|
||||
let host_ip = local_ip().expect("No real IP address!");
|
||||
let figment = Figment::from(Config::default())
|
||||
.merge(("address",host_ip));
|
||||
|
||||
//Allow cross-origin resource sharing
|
||||
let cors = CorsOptions::default()
|
||||
.allowed_origins(AllowedOrigins::all())
|
||||
.allowed_methods(
|
||||
vec![Method::Get, Method::Post]
|
||||
.into_iter()
|
||||
.map(From::from)
|
||||
.collect(),
|
||||
).allow_credentials(true);
|
||||
|
||||
let _rocket = rocket::custom(figment)
|
||||
.attach(cors.to_cors().expect("CORS init failure!"))
|
||||
//.manage(Mutex::new(WorldServerConnection::new().unwrap()))
|
||||
.mount("/", routes![get_data,start_serve,get_serve_data])
|
||||
.launch().await;
|
||||
}
|
187
src/vdma_facade/common.rs
Normal file
187
src/vdma_facade/common.rs
Normal file
|
@ -0,0 +1,187 @@
|
|||
use std::{collections::VecDeque, fs::{File, OpenOptions}, io::{BufRead, BufReader, Error}, os::unix::fs::OpenOptionsExt, path::Path};
|
||||
use crate::vdma_facade::O_SYNC;
|
||||
|
||||
///Stores the result of mapping a given memory section.
|
||||
#[derive(Debug,Copy,Clone)]
|
||||
enum MapResult{
|
||||
UNSET,
|
||||
OK,
|
||||
FAILED
|
||||
}
|
||||
|
||||
///The value to get back from the filesystem.
|
||||
///MapAddress and MapSize require the index of the map being initialised
|
||||
enum MemoryValue{
|
||||
MapAddress(usize),
|
||||
MapSize(usize),
|
||||
Event,
|
||||
Name,
|
||||
Version,
|
||||
}
|
||||
|
||||
///A virtual memory mapping
|
||||
///Includes the address, size of the memory block, and whether it was successfully initialised.
|
||||
#[derive(Debug,Copy,Clone,Default)]
|
||||
pub struct UioMap{
|
||||
address:u32,
|
||||
size:u32,
|
||||
}
|
||||
|
||||
impl UioMap {
|
||||
///Getter for the address
|
||||
pub fn get_address(&self) -> u32 { self.address }
|
||||
///Getter for the size
|
||||
pub fn get_size(&self) -> u32 { self.size }
|
||||
}
|
||||
|
||||
///Information for a given block of virtual memory mappings
|
||||
#[derive(Debug)]
|
||||
pub struct UioInfo{
|
||||
uio_file:File,
|
||||
uio_file_name:String,
|
||||
maps:Vec<UioMap>,
|
||||
event_count:u64,
|
||||
name:String,
|
||||
version:String,
|
||||
dev_attributes:VecDeque<(String,String)>,
|
||||
}
|
||||
|
||||
impl UioInfo{
|
||||
///Creates a new UioInfo object, if possible
|
||||
pub fn new(path:&Path) -> Result<Self,Error> {
|
||||
let uio_file = File::open(path).expect("Unexpected file failure!");
|
||||
let uio_file_name:String = path.file_name().expect("Path cannot be parsed!").to_string_lossy().into();
|
||||
let mut new_info = UioInfo{
|
||||
uio_file,
|
||||
uio_file_name,
|
||||
maps:Vec::new(),
|
||||
event_count:0,
|
||||
name:String::default(),
|
||||
version:String::default(),
|
||||
dev_attributes:VecDeque::new(),
|
||||
};
|
||||
new_info.get_all_info();
|
||||
Ok(new_info)
|
||||
}
|
||||
|
||||
///Pseudo-Display of the UIO device
|
||||
pub fn show_device(&self){
|
||||
println!("{}, name={}, version={}, events={}",
|
||||
self.uio_file_name, self.name, self.version, self.event_count);
|
||||
}
|
||||
|
||||
///Get the UioMap of a particular index from the info, if it exists
|
||||
pub fn get_map(&self,index:u32) -> Option<&UioMap>{
|
||||
self.maps.get(index as usize)
|
||||
}
|
||||
|
||||
///Pseudo-Display of a particular map within the UIO device
|
||||
pub fn show_map(&self, index:usize) {
|
||||
if index > 0 && index < self.maps.len(){
|
||||
let map = &self.maps[index];
|
||||
println!("Map[{}]: {:?}", index, map );
|
||||
}
|
||||
}
|
||||
|
||||
///Pseudo-Display of all maps in the UIO device
|
||||
pub fn show_maps(&self) {
|
||||
for i in 0..self.maps.len(){
|
||||
self.show_map(i);
|
||||
}
|
||||
}
|
||||
|
||||
///Gets the name of the object as found in /sys/class/uio
|
||||
pub fn get_name(&self) -> String { self.name.clone() }
|
||||
|
||||
///Get the file that represents the uio device.
|
||||
pub fn get_device(&self) -> File { self.uio_file.try_clone().expect("File cannot be cloned!") }
|
||||
|
||||
///Get a given memory value from the filesystem.
|
||||
///This should only be used for initialisation.
|
||||
fn get_memory_value(&mut self, search_term:MemoryValue){
|
||||
let uio_path;
|
||||
match search_term{
|
||||
MemoryValue::MapAddress(map_index) =>
|
||||
uio_path = format!("/sys/class/uio/{}/maps/map{}/addr",
|
||||
self.uio_file_name, map_index),
|
||||
MemoryValue::MapSize(map_index) =>
|
||||
uio_path = format!("/sys/class/uio/{}/maps/map{}/size",
|
||||
self.uio_file_name, map_index),
|
||||
MemoryValue::Name =>
|
||||
uio_path = format!("/sys/class/uio/{}/name",
|
||||
self.uio_file_name),
|
||||
MemoryValue::Event =>
|
||||
uio_path = format!("/sys/class/uio/{}/event",
|
||||
self.uio_file_name),
|
||||
MemoryValue::Version =>
|
||||
uio_path = format!("/sys/class/uio/{}/version",
|
||||
self.uio_file_name),
|
||||
}
|
||||
let possible_file = File::open(uio_path);
|
||||
|
||||
match possible_file{
|
||||
Ok(file) => {
|
||||
//All files contain a single value, to which they are being referenced
|
||||
let lines:String = BufReader::new(file).lines().map(|line| line.expect("Bad line!")).collect();
|
||||
match search_term{
|
||||
MemoryValue::MapSize(index) => {
|
||||
let filtered_line = lines.strip_prefix("0x").expect("Bad location!");
|
||||
self.maps[index].size = filtered_line.parse().expect("Impossible error");
|
||||
},
|
||||
MemoryValue::MapAddress(index) => {
|
||||
let filtered_line = lines.strip_prefix("0x").expect("Bad location!");
|
||||
self.maps[index].address = filtered_line.parse().expect("Impossible error");
|
||||
},
|
||||
MemoryValue::Event => {
|
||||
self.event_count = lines.parse().expect("This shouldn't happen");
|
||||
},
|
||||
MemoryValue::Name |
|
||||
MemoryValue::Version => {
|
||||
self.name = lines;
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
println!("{:?}",error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///Wrapper for get_memory_value(); runs all iterations for this object
|
||||
fn get_all_info(&mut self){
|
||||
self.get_memory_value(MemoryValue::Name);
|
||||
self.get_memory_value(MemoryValue::Version);
|
||||
self.get_memory_value(MemoryValue::Event);
|
||||
let sys_location = format!("/sys/class/uio/{}/maps",self.uio_file_name);
|
||||
for i in 0..Path::new(&sys_location).read_dir().expect("").count(){
|
||||
self.get_memory_value(MemoryValue::MapSize(i));
|
||||
self.get_memory_value(MemoryValue::MapAddress(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
///Find all devices on the filesystem, and return them as a VecDeque
|
||||
pub fn find_devices() -> VecDeque<UioInfo>{
|
||||
let mut file_options = OpenOptions::new();
|
||||
file_options.read(true).write(true).custom_flags(O_SYNC);
|
||||
let dev_folder = Path::new("/dev");
|
||||
let mut uio_devices:VecDeque<UioInfo> = VecDeque::new();
|
||||
if dev_folder.is_dir(){
|
||||
for file in dev_folder.read_dir().expect("Invalid path! Check this is being run on Linux"){
|
||||
if let Ok(real_file) = file{
|
||||
let raw_filename = real_file.file_name();
|
||||
let filename:String = raw_filename.to_string_lossy().into();
|
||||
if filename.contains("uio"){
|
||||
let uio_device = UioInfo::new(real_file.path().as_path());
|
||||
match uio_device{
|
||||
Ok(real_device) => uio_devices.push_back(real_device),
|
||||
Err(error) => {
|
||||
error!("{:?}",error);
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
uio_devices
|
||||
}
|
225
src/vdma_facade/feedthrough.rs
Normal file
225
src/vdma_facade/feedthrough.rs
Normal file
|
@ -0,0 +1,225 @@
|
|||
use std::{fs::File, io::Error, os::unix::fs::FileExt, thread, time::Duration};
|
||||
use crate::vdma_facade::common::{find_devices, UioInfo};
|
||||
|
||||
///Object for VDMA control
|
||||
pub struct VdmaHandle{
|
||||
/// File pointing to the VDMA control buffer, along with its information
|
||||
control_buffer:(File, UioInfo),
|
||||
/// Width of the images being parsed; measured in pixels
|
||||
width:u32,
|
||||
/// Height of the images being parsed; measured in pixels
|
||||
height:u32,
|
||||
/// Pixel length, measured in bytes
|
||||
pixel_length:u32,
|
||||
/// Storage of all available framebuffers, along with their information
|
||||
framebuffers:Vec<(File,UioInfo)>,
|
||||
}
|
||||
|
||||
/// Gonna be honest, not sure what this thing is.
|
||||
const OFFSET_PARK_POINTER_REGISTER:u64 = 0x28;
|
||||
|
||||
const REGISTER_RESET:u32 = 0;
|
||||
const NO_MASK_INTERRUPTS:u32 = 0xf;
|
||||
//Commented = unused
|
||||
//const TAIL:u32 = 0x34;
|
||||
//const OFFSET_VERSION:u32 = 0x2c;
|
||||
|
||||
//* Register offsets */
|
||||
//const READ_CONTROL_REGISTER:u64 = 0x00;
|
||||
//const READ_STATUS_REGISTER:u64 = 0x04;
|
||||
//const READ_VSIZE:u64 = 0x50;
|
||||
//const READ_HSIZE:u64 = 0x54;
|
||||
//const READ_FRAMEDELAY_STRIDE:u64 = 0x58;
|
||||
//const READ_FRAMEBUFFER_ONE:u64 = 0x5c;
|
||||
//const READ_FRAMEBUFFER_TWO:u64 = 0x60;
|
||||
//const READ_FRAMEBUFFER_THREE:u64 = 0x64;
|
||||
//const READ_FRAMEBUFFER_FOUR:u64 = 0x68;
|
||||
const WRITE_CONTROL_REGISTER:u64 = 0x30;
|
||||
const WRITE_STATUS_REGISTER:u64 = 0x34;
|
||||
const WRITE_IRQ_MASK:u64 = 0x3c;
|
||||
const WRITE_REGISTER_INDEX:u64 = 0x44;
|
||||
const WRITE_VSIZE:u64 = 0xa0;
|
||||
const WRITE_HSIZE:u64 = 0xa4;
|
||||
const WRITE_FRAMEDELAY_STRIDE:u64 = 0xa8;
|
||||
const WRITE_FRAMEBUFFER_ONE:u64 = 0xac;
|
||||
const WRITE_FRAMEBUFFER_TWO:u64 = 0xb0;
|
||||
const WRITE_FRAMEBUFFER_THREE:u64 = 0xb4;
|
||||
//const WRITE_FRAMEBUFFER_FOUR:u64 = 0xb8;
|
||||
|
||||
///BIT MASKS FOR CONTROL REGISTER
|
||||
///https://docs.amd.com/r/en-US/pg020_axi_vdma/S2MM_VDMACR-S2MM-VDMA-Control-Register-Offset-30h
|
||||
///https://docs.amd.com/r/en-US/pg020_axi_vdma/MM2S_VDMACR-MM2S-VDMA-Control-Register-Offset-00h
|
||||
/// Further reading can be done at the above linked locations, or the pdf in this repository
|
||||
const CONTROL_REGISTER_START:u32 = 0x00000001;
|
||||
const CONTROL_REGISTER_CIRCULAR_PARK:u32 = 0x00000002;
|
||||
const CONTROL_REGISTER_RESET:u32 = 0x00000004;
|
||||
const CONTROL_REGISTER_GENLOCK_ENABLE:u32 = 0x00000008;
|
||||
//const CONTROL_REGISTER_FRAME_COUNT_ENABLE:u32 = 0x00000010;
|
||||
const CONTROL_REGISTER_INTERNAL_GENLOCK:u32 = 0x00000080;
|
||||
//const CONTROL_REGISTER_WRITE_POINTER:u32 = 0x00000f00;
|
||||
//const CONTROL_REGISTER_FRAMECOUNTER_IRQENABLE:u32 = 0x00001000;
|
||||
//const CONTROL_REGISTER_DELAYCOUNT_IRQENABLE:u32 = 0x00002000;
|
||||
//const CONTROL_REGISTER_ERROR_IRQENABLE:u32 = 0x00004000;
|
||||
//const CONTROL_REGISTER_REPEAT_ENABLE:u32 = 0x00008000;
|
||||
//const CONTROL_REGISTER_INTERRUPT_FRAME_COUNT:u32 = 0x00ff0000;
|
||||
//const CONTROL_REGISTER_IRQ_DELAY_COUNT:u32 = 0xff000000;
|
||||
|
||||
///BIT MASKS FOR STATUS REGISTER
|
||||
///https://docs.amd.com/r/en-US/pg020_axi_vdma/S2MM_VDMASR-S2MM-VDMA-Status-Register-Offset-34h
|
||||
///https://docs.amd.com/r/en-US/pg020_axi_vdma/MM2S_VDMASR-MM2S-VDMA-Status-Register-Offset-04h
|
||||
/// Further reading can be done at the above linked locations, or the pdf in this repository
|
||||
const STATUS_REGISTER_HALTED:u32 = 0x00000001;
|
||||
//const STATUS_REGISTER_VDMA_INTENAL_ERROR:u32 = 0x00000010;
|
||||
//const STATUS_REGISTER_VDMA_SLAVE_ERROR:u32 = 0x00000020;
|
||||
//const STATUS_REGISTER_VDMA_DECODE_ERROR:u32 = 0x00000040;
|
||||
//const STATUS_REGISTER_START_OF_FRAME_EARLY_ERROR:u32= 0x00000080;
|
||||
//const STATUS_REGISTER_END_OF_LINE_EARLY_ERROR:u32 = 0x00000100;
|
||||
//const STATUS_REGISTER_START_OF_FRAME_LATE_ERROR:u32 = 0x00000800;
|
||||
//const STATUS_REGISTER_FRAME_COUNT_INTERRUPT:u32 = 0x00001000;
|
||||
//const STATUS_REGISTER_DELAY_COUNT_INTERRUPT:u32 = 0x00002000;
|
||||
//const STATUS_REGISTER_ERROR_INTERRUPT:u32 = 0x00004000;
|
||||
//const STATUS_REGISTER_END_OF_LINE_LATE_ERROR:u32 = 0x00008000;
|
||||
//const STATUS_REGISTER_FRAME_COUNT:u32 = 0x00ff0000;
|
||||
//const STATUS_REGISTER_DELAY_COUNT:u32 = 0xff000000;
|
||||
|
||||
impl VdmaHandle {
|
||||
///Creates a new VDMAHandle
|
||||
pub fn new(width:u32,height:u32,pixel_length:u32) -> Result<VdmaHandle,Error>{
|
||||
//uio0 is control
|
||||
//uio1-3 are framebuffers
|
||||
// for each device in /dev/uio*
|
||||
let mut framebuffers:Vec<(File,UioInfo)> = Vec::new();
|
||||
let mut uio_devices = find_devices();
|
||||
//Control device should always be first; dependent upon DeviceTree implementation
|
||||
let control_device = uio_devices.pop_front().unwrap();
|
||||
let control_buffer = (control_device.get_device(),control_device);
|
||||
|
||||
//Collect all devices
|
||||
for device in uio_devices{
|
||||
framebuffers.push((device.get_device(),device));
|
||||
}
|
||||
|
||||
//Create a new VdmaHandle object
|
||||
let mut new_object = VdmaHandle{ control_buffer,width,height,pixel_length,framebuffers };
|
||||
|
||||
//Start triple buffering.... whatever that means. Kaputa didn't make it very clear,
|
||||
//but it seems to be initialisation things
|
||||
new_object.vdma_start_triple_buffering();
|
||||
|
||||
//Return new initialised object
|
||||
Ok(new_object)
|
||||
}
|
||||
|
||||
///Set a flag in the Vdma control buffer
|
||||
fn set_vdma_flag(&mut self, offset:u64, value:u32) {
|
||||
_ = self.control_buffer.0.write_at(&value.to_le_bytes(), offset);
|
||||
}
|
||||
|
||||
///Get a value from the VDMA control buffer, assuming it exists
|
||||
/// TODO: This might need a unique offset, if the status register != control register.
|
||||
/// I was skimming, so I'm not sure how accurate this is.
|
||||
fn get_vdma_flag(&self, offset:u64) -> Result<u32,Error> {
|
||||
let mut buf = [0;4];
|
||||
let read_length = self.control_buffer.0.read_at(&mut buf, offset);
|
||||
match read_length{
|
||||
Ok(length) => {
|
||||
if length < 4{
|
||||
return Ok(u32::from_le_bytes(buf));
|
||||
} else {
|
||||
return self.get_vdma_flag(offset);
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
error!("{:?}",error);
|
||||
return Err(error);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
///Check if a flag is set to a certain value
|
||||
///
|
||||
///Checking flags is fairly regular on initialisation, this makes the code further down
|
||||
///easier to read.
|
||||
fn flag_is_set(&self, offset:u64, check_val:u32) -> bool{
|
||||
match self.get_vdma_flag(offset){
|
||||
Ok(return_val) => {
|
||||
return_val & check_val == check_val
|
||||
},
|
||||
Err(_) => { false }
|
||||
}
|
||||
}
|
||||
|
||||
///Start triple buffering; aka initialise the buffers
|
||||
///
|
||||
///Still need to poke around and find out what the hell this actually means, but uh...
|
||||
///later me problem. For now, just a translated version of what Kaputa wrote
|
||||
fn vdma_start_triple_buffering(&mut self) {
|
||||
self.set_vdma_flag(WRITE_CONTROL_REGISTER, REGISTER_RESET);
|
||||
|
||||
while !self.flag_is_set(WRITE_CONTROL_REGISTER, CONTROL_REGISTER_RESET)
|
||||
{ thread::sleep(Duration::from_millis(50)); }
|
||||
|
||||
self.set_vdma_flag(WRITE_STATUS_REGISTER, CONTROL_REGISTER_RESET);
|
||||
self.set_vdma_flag(WRITE_IRQ_MASK, NO_MASK_INTERRUPTS);
|
||||
|
||||
let interrupt_frame_count = 3;
|
||||
//Interrupt frame count is stored in mask
|
||||
//CONTROL_REGISTER_INTERRUPT_FRAME_COUNT: 0x00ff0000
|
||||
//The value should, therefore, be shifted left 16
|
||||
let start_flag = (interrupt_frame_count << 16) |
|
||||
//Start VDMA
|
||||
CONTROL_REGISTER_START |
|
||||
// Enable """mutex""" for r/w register.
|
||||
//https://docs.amd.com/r/en-US/pg020_axi_vdma/Genlock-Synchronization?tocId=jzZsIGLYleCfhtxUkvGoug
|
||||
CONTROL_REGISTER_GENLOCK_ENABLE |
|
||||
CONTROL_REGISTER_INTERNAL_GENLOCK |
|
||||
//Enable circular buffer rather than parking
|
||||
//https://docs.amd.com/r/en-US/pg020_axi_vdma/Stream-to-Memory-Map-Register-Detail
|
||||
CONTROL_REGISTER_CIRCULAR_PARK;
|
||||
self.set_vdma_flag(WRITE_CONTROL_REGISTER,start_flag);
|
||||
|
||||
while !self.flag_is_set(WRITE_CONTROL_REGISTER, CONTROL_REGISTER_START) ||
|
||||
self.flag_is_set(WRITE_STATUS_REGISTER, STATUS_REGISTER_HALTED)
|
||||
{ thread::sleep(Duration::from_millis(50)); }
|
||||
|
||||
self.set_vdma_flag(WRITE_REGISTER_INDEX, REGISTER_RESET);
|
||||
|
||||
let framebuffers = &self.framebuffers;
|
||||
let frame_buf_one = framebuffers.get(0).expect("No framebuffers!");
|
||||
let frame_buf_two = framebuffers.get(1).expect("Only one framebuffer!");
|
||||
let frame_buf_three = framebuffers.get(2).expect("Not enough framebuffers");
|
||||
|
||||
let frame_buf_one_addr = frame_buf_one.1.get_map(0).expect("Bad map!").get_address();
|
||||
let frame_buf_two_addr = frame_buf_two.1.get_map(0).expect("Bad map!").get_address();
|
||||
let frame_buf_three_addr = frame_buf_three.1.get_map(0).expect("Bad map!").get_address();
|
||||
|
||||
self.set_vdma_flag(WRITE_FRAMEBUFFER_ONE, frame_buf_one_addr );
|
||||
self.set_vdma_flag(WRITE_FRAMEBUFFER_TWO, frame_buf_two_addr );
|
||||
self.set_vdma_flag(WRITE_FRAMEBUFFER_THREE, frame_buf_three_addr);
|
||||
|
||||
self.set_vdma_flag(OFFSET_PARK_POINTER_REGISTER, REGISTER_RESET);
|
||||
|
||||
self.set_vdma_flag(WRITE_FRAMEDELAY_STRIDE, self.width*self.pixel_length);
|
||||
self.set_vdma_flag(WRITE_HSIZE, self.width*self.pixel_length);
|
||||
self.set_vdma_flag(WRITE_VSIZE, self.height);
|
||||
}
|
||||
///Send image to VDMA
|
||||
///
|
||||
///TODO: Define format being used, be it raw image, or file
|
||||
pub fn set_frame() {
|
||||
//amountWritten = vdma_endpoint.write_at(&[u8;arraySize], offset)
|
||||
//amountWritten = framebuffer.write(&[u8;arraySize]);
|
||||
//From python file:
|
||||
//seek back to the beginning of the file (probably won't need to??? Gonna have to check)
|
||||
//write to file
|
||||
todo!();
|
||||
}
|
||||
///Recieve image from VDMA
|
||||
///
|
||||
///TODO: Not sure how to get this output to file system yet...
|
||||
pub fn get_frame() {
|
||||
//amountRead = vdma_endpoint.read_at(&mut [u8;arraySize], offset)
|
||||
//amountRead = framebuffer.read(&mut [u8;arraySize]);
|
||||
todo!();
|
||||
}
|
||||
}
|
5
src/vdma_facade/mod.rs
Normal file
5
src/vdma_facade/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod feedthrough;
|
||||
pub mod common;
|
||||
|
||||
///See https://docs.rs/libc/latest/libc/constant.O_SYNC.html
|
||||
const O_SYNC:i32 = 1052672;
|
BIN
vdma_docs.pdf
Normal file
BIN
vdma_docs.pdf
Normal file
Binary file not shown.
1956
volley1.csv
Normal file
1956
volley1.csv
Normal file
File diff suppressed because it is too large
Load diff
1447
volley2.csv
Normal file
1447
volley2.csv
Normal file
File diff suppressed because it is too large
Load diff
4182
volley3.csv
Normal file
4182
volley3.csv
Normal file
File diff suppressed because it is too large
Load diff
2067
volley4.csv
Normal file
2067
volley4.csv
Normal file
File diff suppressed because it is too large
Load diff
1396
volley5.csv
Normal file
1396
volley5.csv
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue