Merge "Suggests next team number for data entry"
diff --git a/.bazelignore b/.bazelignore
index 3665125..0b2e110 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -10,3 +10,4 @@
scouting/www/shift_schedule/node_modules
scouting/www/view/node_modules
scouting/www/pit_scouting/node_modules
+scouting/www/scan/node_modules
diff --git a/WORKSPACE b/WORKSPACE
index f56dc7d..1aabe54 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -919,6 +919,7 @@
"@//scouting/www/match_list:package.json",
"@//scouting/www/notes:package.json",
"@//scouting/www/rpc:package.json",
+ "@//scouting/www/scan:package.json",
"@//scouting/www/shift_schedule:package.json",
"@//scouting/www/view:package.json",
],
@@ -1212,6 +1213,14 @@
url = "https://software.frc971.org/Build-Dependencies/2021-10-03_superstructure_shoot_balls.tar.gz",
)
+http_file(
+ name = "opencv_wasm",
+ sha256 = "447244d0e67e411f91e7c225c07f104437104e3e753085248a0c527a25bd8807",
+ urls = [
+ "https://docs.opencv.org/4.9.0/opencv.js",
+ ],
+)
+
http_archive(
name = "opencv_k8",
build_file = "@//debian:opencv.BUILD",
diff --git a/frc971/orin/apriltag_detect.cc b/frc971/orin/apriltag_detect.cc
index 41b687e..9eeb9da 100644
--- a/frc971/orin/apriltag_detect.cc
+++ b/frc971/orin/apriltag_detect.cc
@@ -21,6 +21,9 @@
DEFINE_int32(debug_blob_index, 4096, "Blob to print out for");
+constexpr int kUndistortIterationThreshold = 100;
+constexpr double kUndistortConvergenceEpsilon = 1e-6;
+
extern "C" {
void quad_decode_index(apriltag_detector_t *td, struct quad *quad_original,
@@ -302,7 +305,7 @@
// Dewarps points from the image based on various constants
// Algorithm mainly taken from
-// https://docs.opencv.org/4.x/dc/dbb/tutorial_py_calibration.html
+// https://docs.opencv.org/4.0.0/d9/d0c/group__calib3d.html#ga7dfb72c9cf9780a347fbe3d1c47e5d5a
void ReDistort(double *x, double *y, CameraMatrix *camera_matrix,
DistCoeffs *distortion_coefficients) {
double k1 = distortion_coefficients->k1;
@@ -333,16 +336,16 @@
// https://yangyushi.github.io/code/2020/03/04/opencv-undistort.html
void UnDistort(double *x, double *y, CameraMatrix *camera_matrix,
DistCoeffs *distortion_coefficients) {
- double k1 = distortion_coefficients->k1;
- double k2 = distortion_coefficients->k2;
- double p1 = distortion_coefficients->p1;
- double p2 = distortion_coefficients->p2;
- double k3 = distortion_coefficients->k3;
+ const double k1 = distortion_coefficients->k1;
+ const double k2 = distortion_coefficients->k2;
+ const double p1 = distortion_coefficients->p1;
+ const double p2 = distortion_coefficients->p2;
+ const double k3 = distortion_coefficients->k3;
- double fx = camera_matrix->fx;
- double cx = camera_matrix->cx;
- double fy = camera_matrix->fy;
- double cy = camera_matrix->cy;
+ const double fx = camera_matrix->fx;
+ const double cx = camera_matrix->cx;
+ const double fy = camera_matrix->fy;
+ const double cy = camera_matrix->cy;
double xP = (*x - cx) / fx;
double yP = (*y - cy) / fy;
@@ -350,14 +353,42 @@
double x0 = xP;
double y0 = yP;
- for (int i = 0; i < 10; i++) {
+ double prev_x = 0, prev_y = 0;
+
+ int iterations = 0;
+ do {
+ prev_x = xP;
+ prev_y = yP;
double rSq = xP * xP + yP * yP;
- double linCoef = 1 + k1 * rSq + k2 * rSq * rSq + k3 * rSq * rSq * rSq;
- double kInv = 1 / linCoef;
- double dx = 2 * p1 * xP * yP + p2 * (rSq + k3 * rSq * rSq * rSq);
- double dy = p1 * (rSq + 2 * yP * yP) + 2 * p2 * xP * yP;
- xP = (x0 - dx) * kInv;
- yP = (y0 - dy) * kInv;
+
+ double radial_distortion =
+ 1 + (k1 * rSq) + (k2 * rSq * rSq) + (k3 * rSq * rSq * rSq);
+
+ double radial_distortion_inv = 1 / radial_distortion;
+
+ double tangential_dx = 2 * p1 * xP * yP + p2 * (rSq + k3 * rSq * rSq * rSq);
+ double tangential_dy = p1 * (rSq + 2 * yP * yP) + 2 * p2 * xP * yP;
+
+ xP = (x0 - tangential_dx) * radial_distortion_inv;
+ yP = (y0 - tangential_dy) * radial_distortion_inv;
+
+ if (iterations > kUndistortIterationThreshold) {
+ break;
+ }
+
+ iterations++;
+ } while (std::abs(xP - prev_x) > kUndistortConvergenceEpsilon ||
+ std::abs(yP - prev_y) > kUndistortConvergenceEpsilon);
+
+ if (iterations < kUndistortIterationThreshold) {
+ VLOG(1) << "Took " << iterations << " iterations to reach convergence.";
+ } else {
+ VLOG(1) << "Took " << iterations
+ << " iterations and didn't reach convergence with "
+ << " (xP, yP): "
+ << " (" << xP << ", " << yP << ")"
+ << " vs. (prev_x, prev_y): "
+ << " (" << prev_x << ", " << prev_y << ")";
}
*x = xP * fx + cx;
diff --git a/frc971/vision/target_mapper.cc b/frc971/vision/target_mapper.cc
index c14b032..f5c8dab 100644
--- a/frc971/vision/target_mapper.cc
+++ b/frc971/vision/target_mapper.cc
@@ -23,6 +23,8 @@
DEFINE_double(outlier_std_devs, 1.0,
"Number of standard deviations above average error needed for a "
"constraint to be considered an outlier and get removed.");
+DEFINE_bool(do_map_fitting, false,
+ "Whether to do a final fit of the solved map to the original map");
namespace frc971::vision {
Eigen::Affine3d PoseUtils::Pose3dToAffine3d(
@@ -448,36 +450,38 @@
CHECK(SolveOptimizationProblem(&target_pose_problem_2))
<< "The target pose solve 2 was not successful, exiting.";
- LOG(INFO) << "Solving the overall map's best alignment to the previous map";
- ceres::Problem map_fitting_problem(
- {.loss_function_ownership = ceres::DO_NOT_TAKE_OWNERSHIP});
- std::unique_ptr<ceres::CostFunction> map_fitting_cost_function =
- BuildMapFittingOptimizationProblem(&map_fitting_problem);
- CHECK(SolveOptimizationProblem(&map_fitting_problem))
- << "The map fitting solve was not successful, exiting.";
- map_fitting_cost_function.release();
+ if (FLAGS_do_map_fitting) {
+ LOG(INFO) << "Solving the overall map's best alignment to the previous map";
+ ceres::Problem map_fitting_problem(
+ {.loss_function_ownership = ceres::DO_NOT_TAKE_OWNERSHIP});
+ std::unique_ptr<ceres::CostFunction> map_fitting_cost_function =
+ BuildMapFittingOptimizationProblem(&map_fitting_problem);
+ CHECK(SolveOptimizationProblem(&map_fitting_problem))
+ << "The map fitting solve was not successful, exiting.";
+ map_fitting_cost_function.release();
- Eigen::Affine3d H_frozen_actual = T_frozen_actual_ * R_frozen_actual_;
- LOG(INFO) << "H_frozen_actual: "
- << PoseUtils::Affine3dToPose3d(H_frozen_actual);
+ Eigen::Affine3d H_frozen_actual = T_frozen_actual_ * R_frozen_actual_;
+ LOG(INFO) << "H_frozen_actual: "
+ << PoseUtils::Affine3dToPose3d(H_frozen_actual);
- auto H_world_frozen =
- PoseUtils::Pose3dToAffine3d(target_poses_[FLAGS_frozen_target_id]);
- auto H_world_frozenactual = H_world_frozen * H_frozen_actual;
+ auto H_world_frozen =
+ PoseUtils::Pose3dToAffine3d(target_poses_[FLAGS_frozen_target_id]);
+ auto H_world_frozenactual = H_world_frozen * H_frozen_actual;
- // Offset the solved poses to become the actual ones
- for (auto &[id, pose] : target_poses_) {
- // Don't offset targets we didn't solve for
- if (id < FLAGS_min_target_id || id > FLAGS_max_target_id) {
- continue;
+ // Offset the solved poses to become the actual ones
+ for (auto &[id, pose] : target_poses_) {
+ // Don't offset targets we didn't solve for
+ if (id < FLAGS_min_target_id || id > FLAGS_max_target_id) {
+ continue;
+ }
+
+ // Take the delta between the frozen target and the solved target, and put
+ // that on top of the actual pose of the frozen target
+ auto H_world_solved = PoseUtils::Pose3dToAffine3d(pose);
+ auto H_frozen_solved = H_world_frozen.inverse() * H_world_solved;
+ auto H_world_actual = H_world_frozenactual * H_frozen_solved;
+ pose = PoseUtils::Affine3dToPose3d(H_world_actual);
}
-
- // Take the delta between the frozen target and the solved target, and put
- // that on top of the actual pose of the frozen target
- auto H_world_solved = PoseUtils::Pose3dToAffine3d(pose);
- auto H_frozen_solved = H_world_frozen.inverse() * H_world_solved;
- auto H_world_actual = H_world_frozenactual * H_frozen_solved;
- pose = PoseUtils::Affine3dToPose3d(H_world_actual);
}
auto map_json = MapToJson(field_name);
diff --git a/package.json b/package.json
index 8f05d0f..6853a14 100644
--- a/package.json
+++ b/package.json
@@ -11,18 +11,23 @@
"@angular/core": "v16-lts",
"@angular/forms": "v16-lts",
"@angular/platform-browser": "v16-lts",
+ "@angular/service-worker": "v16-lts",
"@angular/cli": "v16-lts",
"@babel/cli": "^7.16.0",
"@babel/core": "^7.16.0",
"@types/jasmine": "3.10.3",
"@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.6.8",
+ "@types/pako": "2.0.3",
+ "angularx-qrcode": "^16.0.2",
"html-insert-assets": "0.14.3",
"cypress": "13.3.1",
+ "pako": "2.1.0",
"prettier": "2.6.1",
"requirejs": "2.3.6",
"rollup": "4.12.0",
"rxjs": "7.5.7",
+ "dexie": "^3.2.5",
"@rollup/plugin-node-resolve": "15.2.3",
"@types/flatbuffers": "1.10.0",
"@types/node": "20.11.19",
@@ -30,4 +35,4 @@
"terser": "5.16.4",
"zone.js": "^0.13.0"
}
-}
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d44c82a..bf6a429 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@
'@angular/platform-browser':
specifier: v16-lts
version: 16.2.12(@angular/animations@16.2.12)(@angular/common@16.2.12)(@angular/core@16.2.12)
+ '@angular/service-worker':
+ specifier: v16-lts
+ version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)
'@babel/cli':
specifier: ^7.16.0
version: 7.23.9(@babel/core@7.23.9)
@@ -56,12 +59,24 @@
'@types/node':
specifier: 20.11.19
version: 20.11.19
+ '@types/pako':
+ specifier: 2.0.3
+ version: 2.0.3
+ angularx-qrcode:
+ specifier: ^16.0.2
+ version: 16.0.2(@angular/core@16.2.12)
cypress:
specifier: 13.3.1
version: 13.3.1
+ dexie:
+ specifier: ^3.2.5
+ version: 3.2.5(karma@6.4.3)
html-insert-assets:
specifier: 0.14.3
version: 0.14.3
+ pako:
+ specifier: 2.1.0
+ version: 2.1.0
prettier:
specifier: 2.6.1
version: 2.6.1
@@ -102,6 +117,12 @@
'@org_frc971/scouting/www/counter_button':
specifier: workspace:*
version: link:../counter_button
+ '@types/pako':
+ specifier: 2.0.3
+ version: 2.0.3
+ pako:
+ specifier: 2.1.0
+ version: 2.1.0
scouting/www/match_list:
dependencies:
@@ -126,6 +147,18 @@
scouting/www/rpc: {}
+ scouting/www/scan:
+ dependencies:
+ '@angular/forms':
+ specifier: v16-lts
+ version: 16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12)(@angular/platform-browser@16.2.12)(rxjs@7.5.7)
+ '@types/pako':
+ specifier: 2.0.3
+ version: 2.0.3
+ pako:
+ specifier: 2.1.0
+ version: 2.1.0
+
scouting/www/shift_schedule:
dependencies:
'@angular/forms':
@@ -314,6 +347,19 @@
'@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
tslib: 2.6.0
+ /@angular/service-worker@16.2.12(@angular/common@16.2.12)(@angular/core@16.2.12):
+ resolution: {integrity: sha512-o0z0s4c76NmRASa+mUHn/q6vUKQNa06mGmLBDKm84vRQ1sQ2TJv+R1p8K9WkiM5mGy6tjQCDOgaz13TcxMFWOQ==}
+ engines: {node: ^16.14.0 || >=18.10.0}
+ hasBin: true
+ peerDependencies:
+ '@angular/common': 16.2.12
+ '@angular/core': 16.2.12
+ dependencies:
+ '@angular/common': 16.2.12(@angular/core@16.2.12)(rxjs@7.5.7)
+ '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
+ tslib: 2.6.0
+ dev: true
+
/@babel/cli@7.23.9(@babel/core@7.23.9):
resolution: {integrity: sha512-vB1UXmGDNEhcf1jNAHKT9IlYk1R+hehVTLFlCLHBi8gfuHQGP6uRjgXVYU0EVlI/qwAWpstqkBdf2aez3/z/5Q==}
engines: {node: '>=6.9.0'}
@@ -567,7 +613,6 @@
engines: {node: '>=0.1.90'}
requiresBuild: true
dev: true
- optional: true
/@cypress/request@3.0.1:
resolution: {integrity: sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==}
@@ -925,6 +970,10 @@
- supports-color
dev: true
+ /@socket.io/component-emitter@3.1.0:
+ resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
+ dev: true
+
/@tootallnate/once@2.0.0:
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -972,6 +1021,16 @@
'@babel/types': 7.23.9
dev: true
+ /@types/cookie@0.4.1:
+ resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
+ dev: true
+
+ /@types/cors@2.8.17:
+ resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
+ dependencies:
+ '@types/node': 20.11.19
+ dev: true
+
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
dev: true
@@ -984,8 +1043,8 @@
resolution: {integrity: sha512-SWyMrjgdAUHNQmutvDcKablrJhkDLy4wunTme8oYLjKp41GnHGxMRXr2MQMvy/qy8H3LdzwQk9gH4hZ6T++H8g==}
dev: true
- /@types/node@18.19.18:
- resolution: {integrity: sha512-80CP7B8y4PzZF0GWx15/gVWRrB5y/bIjNI84NK3cmQJu0WZwvmj2WMA5LcofQFVfLqqCSp545+U2LsrVzX36Zg==}
+ /@types/node@18.19.22:
+ resolution: {integrity: sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==}
dependencies:
undici-types: 5.26.5
dev: true
@@ -996,6 +1055,9 @@
undici-types: 5.26.5
dev: true
+ /@types/pako@2.0.3:
+ resolution: {integrity: sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==}
+
/@types/resolve@1.20.2:
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
dev: true
@@ -1024,6 +1086,14 @@
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
dev: true
+ /accepts@1.3.8:
+ resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+ engines: {node: '>= 0.6'}
+ dependencies:
+ mime-types: 2.1.35
+ negotiator: 0.6.3
+ dev: true
+
/acorn@8.9.0:
resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==}
engines: {node: '>=0.4.0'}
@@ -1074,6 +1144,16 @@
uri-js: 4.4.1
dev: true
+ /angularx-qrcode@16.0.2(@angular/core@16.2.12):
+ resolution: {integrity: sha512-FztOM7vjNu88sGxUU5jG2I+A9TxZBXXYBWINjpwIBbTL+COMgrtzXnScG7TyQeNknv5w3WFJWn59PcngRRYVXA==}
+ peerDependencies:
+ '@angular/core': ^16.0.0
+ dependencies:
+ '@angular/core': 16.2.12(rxjs@7.5.7)(zone.js@0.13.3)
+ qrcode: 1.5.3
+ tslib: 2.6.0
+ dev: true
+
/ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
@@ -1184,6 +1264,11 @@
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
dev: true
+ /base64id@2.0.0:
+ resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
+ engines: {node: ^4.5.0 || >= 5.9}
+ dev: true
+
/bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
dependencies:
@@ -1211,6 +1296,26 @@
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
dev: true
+ /body-parser@1.20.2:
+ resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==}
+ engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+ dependencies:
+ bytes: 3.1.2
+ content-type: 1.0.5
+ debug: 2.6.9
+ depd: 2.0.0
+ destroy: 1.2.0
+ http-errors: 2.0.0
+ iconv-lite: 0.4.24
+ on-finished: 2.4.1
+ qs: 6.11.0
+ raw-body: 2.5.2
+ type-is: 1.6.18
+ unpipe: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
dependencies:
@@ -1268,6 +1373,11 @@
semver: 7.6.0
dev: true
+ /bytes@3.1.2:
+ resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
/cacache@16.1.3:
resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -1324,6 +1434,11 @@
get-intrinsic: 1.2.1
dev: true
+ /camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+ dev: true
+
/caniuse-lite@1.0.30001588:
resolution: {integrity: sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ==}
dev: true
@@ -1422,6 +1537,22 @@
engines: {node: '>= 10'}
dev: true
+ /cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+ dev: true
+
+ /cliui@7.0.4:
+ resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+ dev: true
+
/cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1496,10 +1627,27 @@
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
+ /connect@3.7.0:
+ resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
+ engines: {node: '>= 0.10.0'}
+ dependencies:
+ debug: 2.6.9
+ finalhandler: 1.1.2
+ parseurl: 1.3.3
+ utils-merge: 1.0.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
dev: true
+ /content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+ dev: true
+
/convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
dev: true
@@ -1508,10 +1656,23 @@
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
dev: true
+ /cookie@0.4.2:
+ resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
+ engines: {node: '>= 0.6'}
+ dev: true
+
/core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
dev: true
+ /cors@2.8.5:
+ resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+ engines: {node: '>= 0.10'}
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+ dev: true
+
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -1521,6 +1682,10 @@
which: 2.0.2
dev: true
+ /custom-event@1.0.1:
+ resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==}
+ dev: true
+
/cypress@13.3.1:
resolution: {integrity: sha512-g4mJLZxYN+UAF2LMy3Znd4LBnUmS59Vynd81VES59RdW48Yt+QtR2cush3melOoVNz0PPbADpWr8DcUx6mif8Q==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
@@ -1529,7 +1694,7 @@
dependencies:
'@cypress/request': 3.0.1
'@cypress/xvfb': 1.2.4(supports-color@8.1.1)
- '@types/node': 18.19.18
+ '@types/node': 18.19.22
'@types/sinonjs__fake-timers': 8.1.1
'@types/sizzle': 2.3.3
arch: 2.2.0
@@ -1579,10 +1744,26 @@
assert-plus: 1.0.0
dev: true
+ /date-format@4.0.14:
+ resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
+ engines: {node: '>=4.0'}
+ dev: true
+
/dayjs@1.11.9:
resolution: {integrity: sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==}
dev: true
+ /debug@2.6.9:
+ resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.0.0
+ dev: true
+
/debug@3.2.7(supports-color@8.1.1):
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -1608,6 +1789,11 @@
supports-color: 8.1.1
dev: true
+ /decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
/deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -1633,6 +1819,42 @@
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
dev: true
+ /depd@2.0.0:
+ resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
+ /destroy@1.2.0:
+ resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+ engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+ dev: true
+
+ /dexie@3.2.5(karma@6.4.3):
+ resolution: {integrity: sha512-MA7vYQvXxWN2+G50D0GLS4FqdYUyRYQsN0FikZIVebOmRoNCSCL9+eUbIF80dqrfns3kmY+83+hE2GN9CnAGyA==}
+ engines: {node: '>=6.0'}
+ dependencies:
+ karma-safari-launcher: 1.0.0(karma@6.4.3)
+ transitivePeerDependencies:
+ - karma
+ dev: true
+
+ /di@0.0.1:
+ resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==}
+ dev: true
+
+ /dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+ dev: true
+
+ /dom-serialize@2.2.1:
+ resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==}
+ dependencies:
+ custom-event: 1.0.1
+ ent: 2.2.0
+ extend: 3.0.2
+ void-elements: 2.0.1
+ dev: true
+
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: true
@@ -1644,6 +1866,10 @@
safer-buffer: 2.1.2
dev: true
+ /ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+ dev: true
+
/electron-to-chromium@1.4.679:
resolution: {integrity: sha512-NhQMsz5k0d6m9z3qAxnsOR/ebal4NAGsrNVRwcDo4Kc/zQ7KdsTKZUxZoygHcVRb0QDW3waEDIcE3isZ79RP6g==}
dev: true
@@ -1656,6 +1882,15 @@
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
dev: true
+ /encode-utf8@1.0.3:
+ resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
+ dev: true
+
+ /encodeurl@1.0.2:
+ resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
/encoding@0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
requiresBuild: true
@@ -1670,6 +1905,31 @@
once: 1.4.0
dev: true
+ /engine.io-parser@5.2.2:
+ resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==}
+ engines: {node: '>=10.0.0'}
+ dev: true
+
+ /engine.io@6.5.4:
+ resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==}
+ engines: {node: '>=10.2.0'}
+ dependencies:
+ '@types/cookie': 0.4.1
+ '@types/cors': 2.8.17
+ '@types/node': 20.11.19
+ accepts: 1.3.8
+ base64id: 2.0.0
+ cookie: 0.4.2
+ cors: 2.8.5
+ debug: 4.3.4(supports-color@8.1.1)
+ engine.io-parser: 5.2.2
+ ws: 8.11.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ dev: true
+
/enquirer@2.3.6:
resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==}
engines: {node: '>=8.6'}
@@ -1677,6 +1937,10 @@
ansi-colors: 4.1.3
dev: true
+ /ent@2.2.0:
+ resolution: {integrity: sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==}
+ dev: true
+
/env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -1691,6 +1955,10 @@
engines: {node: '>=6'}
dev: true
+ /escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+ dev: true
+
/escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
@@ -1704,6 +1972,10 @@
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
dev: true
+ /eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+ dev: true
+
/execa@4.1.0:
resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
engines: {node: '>=10'}
@@ -1786,6 +2058,43 @@
to-regex-range: 5.0.1
dev: true
+ /finalhandler@1.1.2:
+ resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ debug: 2.6.9
+ encodeurl: 1.0.2
+ escape-html: 1.0.3
+ on-finished: 2.3.0
+ parseurl: 1.3.3
+ statuses: 1.5.0
+ unpipe: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+ dev: true
+
+ /flatted@3.3.1:
+ resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
+ dev: true
+
+ /follow-redirects@1.15.5:
+ resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+ dev: true
+
/foreground-child@3.1.1:
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
engines: {node: '>=14'}
@@ -1807,6 +2116,15 @@
mime-types: 2.1.35
dev: true
+ /fs-extra@8.1.0:
+ resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
+ engines: {node: '>=6 <7 || >=8'}
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 4.0.0
+ universalify: 0.1.2
+ dev: true
+
/fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
@@ -2021,6 +2339,17 @@
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
dev: true
+ /http-errors@2.0.0:
+ resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ depd: 2.0.0
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 2.0.1
+ toidentifier: 1.0.1
+ dev: true
+
/http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@@ -2032,6 +2361,17 @@
- supports-color
dev: true
+ /http-proxy@1.18.1:
+ resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
+ engines: {node: '>=8.0.0'}
+ dependencies:
+ eventemitter3: 4.0.7
+ follow-redirects: 1.15.5
+ requires-port: 1.0.0
+ transitivePeerDependencies:
+ - debug
+ dev: true
+
/http-signature@1.3.6:
resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==}
engines: {node: '>=0.10'}
@@ -2257,6 +2597,11 @@
is-docker: 2.2.1
dev: true
+ /isbinaryfile@4.0.10:
+ resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
+ engines: {node: '>= 8.0.0'}
+ dev: true
+
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
@@ -2315,6 +2660,12 @@
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
+ /jsonfile@4.0.0:
+ resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
+ optionalDependencies:
+ graceful-fs: 4.2.11
+ dev: true
+
/jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies:
@@ -2338,6 +2689,50 @@
verror: 1.10.0
dev: true
+ /karma-safari-launcher@1.0.0(karma@6.4.3):
+ resolution: {integrity: sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==}
+ peerDependencies:
+ karma: '>=0.9'
+ dependencies:
+ karma: 6.4.3
+ dev: true
+
+ /karma@6.4.3:
+ resolution: {integrity: sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==}
+ engines: {node: '>= 10'}
+ hasBin: true
+ dependencies:
+ '@colors/colors': 1.5.0
+ body-parser: 1.20.2
+ braces: 3.0.2
+ chokidar: 3.5.3
+ connect: 3.7.0
+ di: 0.0.1
+ dom-serialize: 2.2.1
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ http-proxy: 1.18.1
+ isbinaryfile: 4.0.10
+ lodash: 4.17.21
+ log4js: 6.9.1
+ mime: 2.6.0
+ minimatch: 3.1.2
+ mkdirp: 0.5.6
+ qjobs: 1.2.0
+ range-parser: 1.2.1
+ rimraf: 3.0.2
+ socket.io: 4.7.4
+ source-map: 0.6.1
+ tmp: 0.2.1
+ ua-parser-js: 0.7.37
+ yargs: 16.2.0
+ transitivePeerDependencies:
+ - bufferutil
+ - debug
+ - supports-color
+ - utf-8-validate
+ dev: true
+
/lazy-ass@1.6.0:
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
engines: {node: '> 0.8'}
@@ -2363,6 +2758,13 @@
wrap-ansi: 7.0.0
dev: true
+ /locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+ dependencies:
+ p-locate: 4.1.0
+ dev: true
+
/lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
dev: true
@@ -2389,6 +2791,19 @@
wrap-ansi: 6.2.0
dev: true
+ /log4js@6.9.1:
+ resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ date-format: 4.0.14
+ debug: 4.3.4(supports-color@8.1.1)
+ flatted: 3.3.1
+ rfdc: 1.3.0
+ streamroller: 3.1.5
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/lru-cache@10.2.0:
resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==}
engines: {node: 14 || >=16.14}
@@ -2475,6 +2890,11 @@
- supports-color
dev: true
+ /media-typer@0.3.0:
+ resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+ engines: {node: '>= 0.6'}
+ dev: true
+
/merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
dev: true
@@ -2491,6 +2911,12 @@
mime-db: 1.52.0
dev: true
+ /mime@2.6.0:
+ resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
+ engines: {node: '>=4.0.0'}
+ hasBin: true
+ dev: true
+
/mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -2602,12 +3028,23 @@
yallist: 4.0.0
dev: true
+ /mkdirp@0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.8
+ dev: true
+
/mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
dev: true
+ /ms@2.0.0:
+ resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+ dev: true
+
/ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
dev: true
@@ -2751,10 +3188,29 @@
set-blocking: 2.0.0
dev: true
+ /object-assign@4.1.1:
+ resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
/object-inspect@1.12.3:
resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
dev: true
+ /on-finished@2.3.0:
+ resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ ee-first: 1.1.1
+ dev: true
+
+ /on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ ee-first: 1.1.1
+ dev: true
+
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
@@ -2801,6 +3257,20 @@
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
dev: true
+ /p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+ dependencies:
+ p-try: 2.2.0
+ dev: true
+
+ /p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+ dependencies:
+ p-limit: 2.3.0
+ dev: true
+
/p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
@@ -2808,6 +3278,11 @@
aggregate-error: 3.1.0
dev: true
+ /p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+ dev: true
+
/pacote@15.2.0:
resolution: {integrity: sha512-rJVZeIwHTUta23sIZgEIM62WYwbmGbThdbnkt81ravBplQv+HjyroqnLRNH2+sLJHcGZmLRmhPwACqhfTcOmnA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -2836,10 +3311,23 @@
- supports-color
dev: true
+ /pako@2.1.0:
+ resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
+
/parse5@6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
dev: true
+ /parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
+ /path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+ dev: true
+
/path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@@ -2889,6 +3377,11 @@
engines: {node: '>=6'}
dev: true
+ /pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+ dev: true
+
/prettier@2.6.1:
resolution: {integrity: sha512-8UVbTBYGwN37Bs9LERmxCPjdvPxlEowx2urIL6urHzdb3SDq4B/Z6xLFCblrSnE4iKWcS6ziJ3aOYrc1kz/E2A==}
engines: {node: '>=10.13.0'}
@@ -2947,6 +3440,22 @@
engines: {node: '>=6'}
dev: true
+ /qjobs@1.2.0:
+ resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==}
+ engines: {node: '>=0.9'}
+ dev: true
+
+ /qrcode@1.5.3:
+ resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+ dependencies:
+ dijkstrajs: 1.0.3
+ encode-utf8: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+ dev: true
+
/qs@6.10.4:
resolution: {integrity: sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==}
engines: {node: '>=0.6'}
@@ -2954,10 +3463,32 @@
side-channel: 1.0.4
dev: true
+ /qs@6.11.0:
+ resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
+ engines: {node: '>=0.6'}
+ dependencies:
+ side-channel: 1.0.4
+ dev: true
+
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
dev: true
+ /range-parser@1.2.1:
+ resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+ engines: {node: '>= 0.6'}
+ dev: true
+
+ /raw-body@2.5.2:
+ resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.0
+ iconv-lite: 0.4.24
+ unpipe: 1.0.0
+ dev: true
+
/read-package-json-fast@3.0.2:
resolution: {integrity: sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -3012,6 +3543,10 @@
engines: {node: '>=0.10.0'}
dev: true
+ /require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+ dev: true
+
/requirejs@2.3.6:
resolution: {integrity: sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==}
engines: {node: '>=0.4.0'}
@@ -3132,6 +3667,10 @@
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
dev: true
+ /setprototypeof@1.2.0:
+ resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ dev: true
+
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3203,6 +3742,44 @@
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
dev: true
+ /socket.io-adapter@2.5.4:
+ resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==}
+ dependencies:
+ debug: 4.3.4(supports-color@8.1.1)
+ ws: 8.11.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ dev: true
+
+ /socket.io-parser@4.2.4:
+ resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
+ engines: {node: '>=10.0.0'}
+ dependencies:
+ '@socket.io/component-emitter': 3.1.0
+ debug: 4.3.4(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /socket.io@4.7.4:
+ resolution: {integrity: sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==}
+ engines: {node: '>=10.2.0'}
+ dependencies:
+ accepts: 1.3.8
+ base64id: 2.0.0
+ cors: 2.8.5
+ debug: 4.3.4(supports-color@8.1.1)
+ engine.io: 6.5.4
+ socket.io-adapter: 2.5.4
+ socket.io-parser: 4.2.4
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ dev: true
+
/socks-proxy-agent@7.0.0:
resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==}
engines: {node: '>= 10'}
@@ -3291,6 +3868,27 @@
minipass: 3.3.6
dev: true
+ /statuses@1.5.0:
+ resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+ engines: {node: '>= 0.6'}
+ dev: true
+
+ /statuses@2.0.1:
+ resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
+ /streamroller@3.1.5:
+ resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ date-format: 4.0.14
+ debug: 4.3.4(supports-color@8.1.1)
+ fs-extra: 8.1.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
/string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -3422,6 +4020,11 @@
is-number: 7.0.0
dev: true
+ /toidentifier@1.0.1:
+ resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+ engines: {node: '>=0.6'}
+ dev: true
+
/tough-cookie@4.1.3:
resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==}
engines: {node: '>=6'}
@@ -3461,12 +4064,24 @@
engines: {node: '>=10'}
dev: true
+ /type-is@1.6.18:
+ resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+ engines: {node: '>= 0.6'}
+ dependencies:
+ media-typer: 0.3.0
+ mime-types: 2.1.35
+ dev: true
+
/typescript@5.1.6:
resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
+ /ua-parser-js@0.7.37:
+ resolution: {integrity: sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==}
+ dev: true
+
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true
@@ -3499,6 +4114,11 @@
imurmurhash: 0.1.4
dev: true
+ /universalify@0.1.2:
+ resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
+ engines: {node: '>= 4.0.0'}
+ dev: true
+
/universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
@@ -3509,6 +4129,11 @@
engines: {node: '>= 10.0.0'}
dev: true
+ /unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
/untildify@4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'}
@@ -3542,6 +4167,11 @@
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
+ /utils-merge@1.0.1:
+ resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
+ engines: {node: '>= 0.4.0'}
+ dev: true
+
/uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -3561,6 +4191,11 @@
builtins: 5.0.1
dev: true
+ /vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+ dev: true
+
/verror@1.10.0:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
@@ -3570,12 +4205,21 @@
extsprintf: 1.3.0
dev: true
+ /void-elements@2.0.1:
+ resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
/wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
dependencies:
defaults: 1.0.4
dev: true
+ /which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+ dev: true
+
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3629,6 +4273,23 @@
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
+ /ws@8.11.0:
+ resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: true
+
+ /y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+ dev: true
+
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -3642,11 +4303,54 @@
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
dev: true
+ /yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+ dev: true
+
+ /yargs-parser@20.2.9:
+ resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
+ engines: {node: '>=10'}
+ dev: true
+
/yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
dev: true
+ /yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+ dev: true
+
+ /yargs@16.2.0:
+ resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
+ engines: {node: '>=10'}
+ dependencies:
+ cliui: 7.0.4
+ escalade: 3.1.1
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 20.2.9
+ dev: true
+
/yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
diff --git a/scouting/BUILD b/scouting/BUILD
index 82f058c..ba6ab6b 100644
--- a/scouting/BUILD
+++ b/scouting/BUILD
@@ -38,6 +38,22 @@
],
)
+# The QR code test is separate from scouting_test because it's slow. Most of
+# the time folks will want to iterate on `scouting_test`.
+cypress_test(
+ name = "scouting_qrcode_test",
+ size = "large",
+ data = [
+ "scouting_qrcode_test.cy.js",
+ "//scouting/testing:scouting_test_servers",
+ "//scouting/testing/camera_simulator",
+ ],
+ runner = "scouting_test_runner.js",
+ tags = [
+ "no-remote-cache",
+ ],
+)
+
apache_wrapper(
name = "https",
binary = ":scouting",
diff --git a/scouting/scouting_qrcode_test.cy.js b/scouting/scouting_qrcode_test.cy.js
new file mode 100644
index 0000000..668cba8
--- /dev/null
+++ b/scouting/scouting_qrcode_test.cy.js
@@ -0,0 +1,157 @@
+/// <reference types="cypress" />
+
+// On the 3rd row of matches (index 2) click on the third team
+// (index 2) which resolves to team 333 in quals match 3.
+const QUALS_MATCH_3_TEAM_333 = 2 * 6 + 2;
+
+function disableAlerts() {
+ cy.get('#block_alerts').check({force: true}).should('be.checked');
+}
+
+function switchToTab(tabName) {
+ cy.contains('.nav-link', tabName).click();
+}
+
+function headerShouldBe(text) {
+ cy.get('.header').should('have.text', text);
+}
+
+function clickButton(buttonName) {
+ cy.contains('button', buttonName).click();
+}
+
+// Wrapper around cy.exec() because it truncates the output of the subprocess
+// if it fails. This is a work around to manually print the full error on the
+// console if a failure happends.
+function exec(command) {
+ cy.exec(command, {failOnNonZeroExit: false}).then((result) => {
+ if (result.code) {
+ throw new Error(`Execution of "${command}" failed
+ Exit code: ${result.code}
+ Stdout:\n${result.stdout}
+ Stderr:\n${result.stderr}`);
+ }
+ });
+}
+
+// Prepares data entry so that we _could_ hit Submit.
+//
+// Options:
+// matchButtonKey: The index into the big matchlist table that we want to
+// click on to start the data entry.
+// teamNumber: The team number that matches the button that we click on as
+// specified by `matchButtonKey`.
+//
+// TODO(phil): Deduplicate with scouting_test.cy.js.
+function prepareDataScouting(options) {
+ const {matchButtonKey = SEMI_FINAL_2_MATCH_3_TEAM_5254, teamNumber = 5254} =
+ options;
+
+ // Click on a random team in the Match list. The exact details here are not
+ // important, but we need to know what they are. This could as well be any
+ // other team from any other match.
+ cy.get('button.match-item').eq(matchButtonKey).click();
+
+ // Select Starting Position.
+ headerShouldBe(teamNumber + ' Init ');
+ cy.get('[type="radio"]').first().check();
+ clickButton('Start Match');
+
+ // Pick and Place Note in Auto.
+ clickButton('NOTE');
+ clickButton('AMP');
+
+ // Pick and Place Cube in Teleop.
+ clickButton('Start Teleop');
+ clickButton('NOTE');
+ clickButton('AMP AMPLIFIED');
+
+ // Generate some extra actions so that we are guaranteed to have at least 2
+ // QR codes.
+ for (let i = 0; i < 5; i++) {
+ clickButton('NOTE');
+ clickButton('AMP');
+ }
+
+ // Robot dead and revive.
+ clickButton('DEAD');
+ clickButton('Revive');
+
+ // Endgame.
+ clickButton('Endgame');
+ cy.contains(/Harmony/).click();
+
+ clickButton('End Match');
+ headerShouldBe(teamNumber + ' Review and Submit ');
+ cy.get('#review_data li')
+ .eq(0)
+ .should('have.text', ' Started match at position 1 ');
+ cy.get('#review_data li').eq(1).should('have.text', 'Picked up Note');
+ cy.get('#review_data li')
+ .last()
+ .should(
+ 'have.text',
+ ' Ended Match; stageType: kHARMONY, trapNote: false, spotlight: false '
+ );
+}
+
+before(() => {
+ cy.visit('/');
+ disableAlerts();
+ cy.title().should('eq', 'FRC971 Scouting Application');
+});
+
+beforeEach(() => {
+ cy.visit('/');
+ disableAlerts();
+});
+
+describe('Scouting app tests', () => {
+ // This test collects some scouting data and then generates the corresponding
+ // QR codes. The test takes screenshots of those QR codes. The QR codes get
+ // turned into a little video file for the browser to use as a fake camera
+ // input. The test then switches to the Scan tab to scan the QR codes from
+ // the "camera". We then make sure that the data gets submitted.
+ it('should: be able to generate and scan QR codes.', () => {
+ prepareDataScouting({
+ matchButtonKey: QUALS_MATCH_3_TEAM_333,
+ teamNumber: 333,
+ });
+ clickButton('Create QR Code');
+ headerShouldBe('333 QR Code ');
+
+ cy.get('#qr_code_piece_size').select('150');
+
+ // Go into a mobile-phone view so that we can guarantee that the QR code is
+ // visible.
+ cy.viewport(400, 660);
+
+ cy.get('.qrcode-buttons > li > a')
+ .should('have.length.at.least', 4)
+ .each(($button, index, $buttons) => {
+ if (index == 0 || index + 1 == $buttons.length) {
+ // Skip the "Previous" and "Next" buttons.
+ return;
+ }
+ // Click on the button to switch to that particular QR code.
+ // We use force:true here because without bootstrap (inside the
+ // sandbox) the buttons overlap one another a bit.
+ cy.wrap($button).click({force: true});
+ cy.get('div.qrcode').screenshot(`qrcode_${index}_screenshot`);
+ });
+
+ exec('./testing/camera_simulator/camera_simulator_/camera_simulator');
+
+ switchToTab('Scan');
+
+ // Since we cannot reliably predict how long it will take to scan all the
+ // QR codes, we use a really long timeout here.
+ cy.get('.progress_message', {timeout: 80000}).should('contain', 'Success!');
+
+ // Now that the data is submitted, the button should be disabled.
+ switchToTab('Match List');
+ cy.get('button.match-item')
+ .eq(QUALS_MATCH_3_TEAM_333)
+ .should('be.disabled');
+ });
+});
diff --git a/scouting/scouting_test.cy.js b/scouting/scouting_test.cy.js
index 31ca935..d15cdfc 100644
--- a/scouting/scouting_test.cy.js
+++ b/scouting/scouting_test.cy.js
@@ -117,15 +117,27 @@
clickButton('Submit');
headerShouldBe(teamNumber + ' Success ');
}
+function visit(path) {
+ cy.visit(path, {
+ onBeforeLoad(win) {
+ // The service worker seems to interfere with Cypress somehow. There
+ // doesn't seem to be a proper fix for this issue. Work around it with
+ // this hack that disables the service worker.
+ // https://github.com/cypress-io/cypress/issues/16192#issuecomment-870421667
+ // https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
+ delete win.navigator.__proto__.serviceWorker;
+ },
+ });
+}
before(() => {
- cy.visit('/');
+ visit('/');
disableAlerts();
cy.title().should('eq', 'FRC971 Scouting Application');
});
beforeEach(() => {
- cy.visit('/');
+ visit('/');
disableAlerts();
});
diff --git a/scouting/testing/camera_simulator/BUILD b/scouting/testing/camera_simulator/BUILD
new file mode 100644
index 0000000..b6e4bf3
--- /dev/null
+++ b/scouting/testing/camera_simulator/BUILD
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "camera_simulator_lib",
+ srcs = ["camera_simulator.go"],
+ importpath = "github.com/frc971/971-Robot-Code/scouting/testing/camera_simulator",
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:private"],
+)
+
+go_binary(
+ name = "camera_simulator",
+ embed = [":camera_simulator_lib"],
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:public"],
+)
diff --git a/scouting/testing/camera_simulator/camera_simulator.go b/scouting/testing/camera_simulator/camera_simulator.go
new file mode 100644
index 0000000..a54e79f
--- /dev/null
+++ b/scouting/testing/camera_simulator/camera_simulator.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/jpeg"
+ _ "image/png"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// Chrome plays back MJPEG files at a (hard-coded) 30 fps.
+const CHROME_FAKE_VIDEO_FPS = 30
+
+// For how many seconds to display a single image.
+const IMAGE_DURATION = 3
+
+// For how many frames (at CHROME_FAKE_VIDEO_FPS) to display a single image.
+const IMAGE_DURATION_FRAMES = int(CHROME_FAKE_VIDEO_FPS * IMAGE_DURATION)
+
+func checkErr(err error, message string) {
+ if err != nil {
+ log.Println(message)
+ log.Fatal(err)
+ }
+}
+
+func main() {
+ output_dir := os.Getenv("TEST_UNDECLARED_OUTPUTS_DIR")
+
+ // The output file is at a fixed path as expected by
+ // `tools/build_rules/js/cypress.config.js`.
+ outName := output_dir + "/fake_camera.mjpeg"
+
+ // The Cypress test is expected to dump all the screenshots in this
+ // directory.
+ screenshotDir := output_dir + "/screenshots/scouting_qrcode_test.cy.js"
+ log.Printf("Looking for screenshots in %s", screenshotDir)
+
+ // Create a movie from images.
+ matches, err := filepath.Glob(screenshotDir + "/qrcode_*_screenshot.png")
+ checkErr(err, "Failed to glob for the screenshots")
+ sort.Strings(matches)
+
+ log.Println("Found images:", matches)
+ if len(matches) < 2 {
+ // For the purposes of the test, we expect at least 2 QR codes.
+ // If something goes wrong, then this is an opportunity to bail
+ // early.
+ log.Fatalf("Only found %d images", len(matches))
+ }
+
+ mjpeg, err := os.Create(outName)
+ checkErr(err, "Failed to open output file")
+ defer mjpeg.Close()
+
+ // MJPEG is litterally a bunch of JPEGs concatenated together. Read in
+ // each frame and append it to the output file.
+ for _, name := range matches {
+ reader, err := os.Open(name)
+ checkErr(err, "Could not open "+name)
+ defer reader.Close()
+
+ img, _, err := image.Decode(reader)
+ checkErr(err, "Could not decode image")
+
+ buffer := &bytes.Buffer{}
+ checkErr(jpeg.Encode(buffer, img, nil), "Failed to encode as jpeg")
+
+ // In order to show a single picture for 1 second, we need to
+ // inject CHROME_FAKE_VIDEO_FPS copies of the same image.
+ for i := 0; i < IMAGE_DURATION_FRAMES; i++ {
+ _, err = mjpeg.Write(buffer.Bytes())
+ checkErr(err, "Failed to write to mjpeg")
+ }
+ }
+
+ fmt.Printf("%s was written successfully.\n", outName)
+}
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index a403bf3..a6ca0a1 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -1,25 +1,49 @@
load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
+load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary")
+load("@npm//:@angular/service-worker/package_json.bzl", angular_service_worker = "bin")
load("@npm//:defs.bzl", "npm_link_all_packages")
load("//tools/build_rules:js.bzl", "ng_application")
-load(":defs.bzl", "assemble_static_files")
+load(":defs.bzl", "assemble_service_worker_files", "assemble_static_files")
npm_link_all_packages(name = "node_modules")
+assemble_service_worker_files(
+ name = "service_worker_files",
+ outs = [
+ "ngsw-worker.js",
+ ],
+)
+
+OPENCV_VERSION = "4.9.0"
+
+copy_file(
+ name = "opencv.js",
+ src = "@opencv_wasm//file",
+ out = "assets/opencv_{}/opencv.js".format(OPENCV_VERSION),
+)
+
ng_application(
name = "app",
+ assets = [
+ "manifest.json",
+ ":opencv.js",
+ ],
extra_srcs = [
"app/common.css",
],
html_assets = [
"favicon.ico",
+ "assets/971_144.png",
],
deps = [
"//:node_modules/@angular/animations",
+ "//:node_modules/@angular/service-worker",
"//scouting/www/driver_ranking",
"//scouting/www/entry",
"//scouting/www/match_list",
"//scouting/www/notes",
"//scouting/www/pit_scouting",
+ "//scouting/www/scan",
"//scouting/www/shift_schedule",
"//scouting/www/view",
],
@@ -30,6 +54,8 @@
app_files = ":app",
pictures = [
"//third_party/y2024/field:pictures",
+ ":ngsw-worker.js",
+ ":ngsw.json",
],
replace_prefixes = {
"prod": "",
@@ -48,3 +74,35 @@
out = "app/common.css",
visibility = ["//scouting/www:__subpackages__"],
)
+
+angular_service_worker.ngsw_config_binary(
+ name = "ngsw_config_binary",
+)
+
+js_binary(
+ name = "ngsw_config_wrapper",
+ data = [
+ ":ngsw_config_binary",
+ ],
+ entry_point = "ngsw_config_wrapper.js",
+)
+
+js_run_binary(
+ name = "ngsw_config",
+ srcs = [
+ "manifest.json",
+ "ngsw-config.json",
+ ":app",
+ ":ngsw_config_binary",
+ ],
+ outs = [
+ "ngsw.json",
+ ],
+ args = [
+ "$(rootpath :ngsw_config_binary)",
+ "$(rootpath :ngsw.json)",
+ "$(rootpath :prod)",
+ "$(rootpath ngsw-config.json)",
+ ],
+ tool = ":ngsw_config_wrapper",
+)
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index bf393e4..decd1f3 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -1,6 +1,7 @@
-import {NgModule} from '@angular/core';
+import {NgModule, isDevMode} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+import {ServiceWorkerModule} from '@angular/service-worker';
import {App} from './app';
import {EntryModule} from '../entry';
@@ -10,12 +11,19 @@
import {ViewModule} from '../view';
import {DriverRankingModule} from '../driver_ranking';
import {PitScoutingModule} from '../pit_scouting';
+import {ScanModule} from '../scan';
@NgModule({
declarations: [App],
imports: [
BrowserModule,
BrowserAnimationsModule,
+ ServiceWorkerModule.register('./ngsw-worker.js', {
+ enabled: !isDevMode(),
+ // Register the ServiceWorker as soon as the application is stable
+ // or after 30 seconds (whichever comes first).
+ registrationStrategy: 'registerWhenStable:30000',
+ }),
EntryModule,
NotesModule,
MatchListModule,
@@ -23,6 +31,7 @@
DriverRankingModule,
ViewModule,
PitScoutingModule,
+ ScanModule,
],
exports: [App],
bootstrap: [App],
diff --git a/scouting/www/app/app.ng.html b/scouting/www/app/app.ng.html
index 5f7ea9f..ef37c07 100644
--- a/scouting/www/app/app.ng.html
+++ b/scouting/www/app/app.ng.html
@@ -73,6 +73,15 @@
Pit
</a>
</li>
+ <li class="nav-item">
+ <a
+ class="nav-link"
+ [class.active]="tabIs('Scan')"
+ (click)="switchTabToGuarded('Scan')"
+ >
+ Scan
+ </a>
+ </li>
</ul>
<ng-container [ngSwitch]="tab">
@@ -93,4 +102,5 @@
<shift-schedule *ngSwitchCase="'ShiftSchedule'"></shift-schedule>
<app-view *ngSwitchCase="'View'"></app-view>
<app-pit-scouting *ngSwitchCase="'Pit'"></app-pit-scouting>
+ <app-scan *ngSwitchCase="'Scan'"></app-scan>
</ng-container>
diff --git a/scouting/www/app/app.ts b/scouting/www/app/app.ts
index 597e5c5..ab15ae5 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -1,4 +1,4 @@
-import {Component, ElementRef, ViewChild} from '@angular/core';
+import {Component, ElementRef, ViewChild, isDevMode} from '@angular/core';
type Tab =
| 'MatchList'
@@ -7,10 +7,11 @@
| 'DriverRanking'
| 'ShiftSchedule'
| 'View'
- | 'Pit';
+ | 'Pit'
+ | 'Scan';
// Ignore the guard for tabs that don't require the user to enter any data.
-const unguardedTabs: Tab[] = ['MatchList', 'View'];
+const unguardedTabs: Tab[] = ['MatchList', 'Scan', 'View'];
type TeamInMatch = {
teamNumber: string;
@@ -39,6 +40,8 @@
@ViewChild('block_alerts') block_alerts: ElementRef;
constructor() {
+ console.log(`Using development mode: ${isDevMode()}`);
+
window.addEventListener('beforeunload', (e) => {
if (!unguardedTabs.includes(this.tab)) {
if (!this.block_alerts.nativeElement.checked) {
diff --git a/scouting/www/assets/971_144.png b/scouting/www/assets/971_144.png
new file mode 100644
index 0000000..881edfa
--- /dev/null
+++ b/scouting/www/assets/971_144.png
Binary files differ
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
index 828f30a..e7fb44a 100644
--- a/scouting/www/defs.bzl
+++ b/scouting/www/defs.bzl
@@ -27,6 +27,7 @@
),
"pictures": attr.label_list(
mandatory = True,
+ allow_files = True,
),
"replace_prefixes": attr.string_dict(
mandatory = True,
@@ -34,3 +35,45 @@
},
toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
)
+
+def _assemble_service_worker_files_impl(ctx):
+ args = ctx.actions.args()
+ args.add_all(ctx.attr._package.files, before_each = "--input_dir", expand_directories = False)
+ args.add_all(ctx.outputs.outs, before_each = "--output")
+ args.add_all(ctx.attr.outs_as_strings, before_each = "--relative_output")
+ ctx.actions.run(
+ inputs = ctx.attr._package.files,
+ outputs = ctx.outputs.outs,
+ executable = ctx.executable._tool,
+ arguments = [args],
+ mnemonic = "AssembleAngularServiceWorker",
+ )
+
+_assemble_service_worker_files = rule(
+ implementation = _assemble_service_worker_files_impl,
+ attrs = {
+ "outs": attr.output_list(
+ allow_empty = False,
+ mandatory = True,
+ ),
+ "outs_as_strings": attr.string_list(
+ allow_empty = False,
+ mandatory = True,
+ ),
+ "_package": attr.label(
+ default = "//:node_modules/@angular/service-worker",
+ ),
+ "_tool": attr.label(
+ default = "//tools/build_rules/js:assemble_service_worker_files",
+ cfg = "exec",
+ executable = True,
+ ),
+ },
+)
+
+def assemble_service_worker_files(outs, **kwargs):
+ _assemble_service_worker_files(
+ outs = outs,
+ outs_as_strings = outs,
+ **kwargs
+ )
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 98b457b..48732f9 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -10,6 +10,10 @@
],
deps = [
":node_modules/@angular/forms",
+ "//:node_modules/@angular/platform-browser",
+ "//:node_modules/@types/pako",
+ "//:node_modules/angularx-qrcode",
+ "//:node_modules/pako",
"//scouting/webserver/requests/messages:error_response_ts_fbs",
"//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
"//scouting/webserver/requests/messages:submit_2024_actions_ts_fbs",
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index cd208b9..e646a25 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -28,3 +28,43 @@
width: 90vw;
justify-content: space-between;
}
+
+.qr-container {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ padding: 0px;
+}
+
+.qrcode-buttons > li.page-item {
+ padding: 0px;
+}
+
+/* Using deprecated ::ng-deep here, but couldn't find a better way to do it.
+ * The qrcode container generates a canvas without any style markers or
+ * classes. Angular's view encapsulation prevents this style from applying.
+ * Maybe https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted ? */
+:host ::ng-deep .qr-container canvas {
+ /* Make the QR code take up as much space as possible. Can't take up more
+ * than the screen size, however, because you can't scan a QR code while
+ * scrolling. It needs to be fully visibile. */
+ width: 100% !important;
+ height: 100% !important;
+ aspect-ratio: 1 / 1;
+}
+
+/* Make the UI a little more compact. The QR code itself already has a good
+ * amount of margin. */
+
+.qrcode-nav {
+ padding: 0px;
+}
+
+.qrcode-buttons {
+ padding: 0px;
+ margin: 0px;
+}
+
+.qrcode {
+ padding: 0px;
+}
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 7be54b3..8ede3a2 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -26,6 +26,7 @@
} from '../../webserver/requests/messages/submit_2024_actions_generated';
import {Match} from '../../webserver/requests/messages/request_all_matches_response_generated';
import {MatchListRequestor} from '../rpc';
+import * as pako from 'pako';
type Section =
| 'Team Selection'
@@ -35,6 +36,7 @@
| 'Endgame'
| 'Dead'
| 'Review and Submit'
+ | 'QR Code'
| 'Success';
// TODO(phil): Deduplicate with match_list.component.ts.
@@ -50,6 +52,13 @@
f: 'Finals',
};
+// The maximum number of bytes per QR code. The user can adjust this value to
+// make the QR code contain less information, but easier to scan.
+const QR_CODE_PIECE_SIZES = [150, 300, 450, 600, 750, 900];
+
+// The default index into QR_CODE_PIECE_SIZES.
+const DEFAULT_QR_CODE_PIECE_SIZE_INDEX = QR_CODE_PIECE_SIZES.indexOf(750);
+
type ActionT =
| {
type: 'startMatchAction';
@@ -112,6 +121,7 @@
// of radio buttons.
readonly COMP_LEVELS = COMP_LEVELS;
readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
+ readonly QR_CODE_PIECE_SIZES = QR_CODE_PIECE_SIZES;
readonly ScoreType = ScoreType;
readonly StageType = StageType;
@@ -138,6 +148,16 @@
teamSelectionIsValid = false;
+ // When the user chooses to generate QR codes, we convert the flatbuffer into
+ // a long string. Since we frequently have more data than we can display in a
+ // single QR code, we break the data into multiple QR codes. The data for
+ // each QR code ("pieces") is stored in the `qrCodeValuePieces` list below.
+ // The `qrCodeValueIndex` keeps track of which QR code we're currently
+ // displaying.
+ qrCodeValuePieceSize = QR_CODE_PIECE_SIZES[DEFAULT_QR_CODE_PIECE_SIZE_INDEX];
+ qrCodeValuePieces: string[] = [];
+ qrCodeValueIndex: number = 0;
+
constructor(private readonly matchListRequestor: MatchListRequestor) {}
ngOnInit() {
@@ -223,7 +243,8 @@
}
if (action.type == 'endMatchAction') {
- // endMatchAction occurs at the same time as penaltyAction so add to its timestamp to make it unique.
+ // endMatchAction occurs at the same time as penaltyAction so add to its
+ // timestamp to make it unique.
action.timestamp += 1;
}
@@ -286,6 +307,11 @@
this.errorMessage = '';
this.progressMessage = '';
+ // For the QR code screen, we need to make the value to encode available.
+ if (target == 'QR Code') {
+ this.updateQrCodeValuePieceSize();
+ }
+
this.section = target;
}
@@ -295,7 +321,7 @@
this.header.nativeElement.scrollIntoView();
}
- async submit2024Actions() {
+ createActionsBuffer() {
const builder = new Builder();
const actionOffsets: number[] = [];
@@ -423,10 +449,44 @@
Submit2024Actions.addPreScouting(builder, this.preScouting);
builder.finish(Submit2024Actions.endSubmit2024Actions(builder));
- const buffer = builder.asUint8Array();
+ return builder.asUint8Array();
+ }
+
+ // Same as createActionsBuffer, but encoded as Base64. It's also split into
+ // a number of pieces so that each piece is roughly limited to
+ // `qrCodeValuePieceSize` bytes.
+ createBase64ActionsBuffers(): string[] {
+ const originalBuffer = this.createActionsBuffer();
+ const deflatedData = pako.deflate(originalBuffer, {level: 9});
+
+ const pieceSize = this.qrCodeValuePieceSize;
+ const fullValue = btoa(String.fromCharCode(...deflatedData));
+ const numPieces = Math.ceil(fullValue.length / pieceSize);
+
+ let splitData: string[] = [];
+ for (let i = 0; i < numPieces; i++) {
+ const splitPiece = fullValue.slice(i * pieceSize, (i + 1) * pieceSize);
+ splitData.push(`${i}_${numPieces}_${pieceSize}_${splitPiece}`);
+ }
+ return splitData;
+ }
+
+ setQrCodeValueIndex(index: number) {
+ this.qrCodeValueIndex = Math.max(
+ 0,
+ Math.min(index, this.qrCodeValuePieces.length - 1)
+ );
+ }
+
+ updateQrCodeValuePieceSize() {
+ this.qrCodeValuePieces = this.createBase64ActionsBuffers();
+ this.qrCodeValueIndex = 0;
+ }
+
+ async submit2024Actions() {
const res = await fetch('/requests/submit/submit_2024_actions', {
method: 'POST',
- body: buffer,
+ body: this.createActionsBuffer(),
});
if (res.ok) {
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index c74d4d0..bcba4ee 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -2,10 +2,11 @@
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {EntryComponent} from './entry.component';
+import {QRCodeModule} from 'angularx-qrcode';
@NgModule({
declarations: [EntryComponent],
exports: [EntryComponent],
- imports: [CommonModule, FormsModule],
+ imports: [CommonModule, FormsModule, QRCodeModule],
})
export class EntryModule {}
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 32ced4d..553fa1e 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -429,7 +429,6 @@
Revive
</button>
<button
- *ngIf="!autoPhase"
class="btn btn-info"
(click)="changeSectionTo('Review and Submit'); addPenalties(); addAction({type: 'endMatchAction', stageType: (park.checked ? StageType.kPARK : onStage.checked ? StageType.kON_STAGE : harmony.checked ? StageType.kHARMONY : StageType.kMISSING), trapNote: trapNote.checked, spotlight: spotlight.checked});"
>
@@ -476,6 +475,9 @@
</div>
<div class="d-grid gap-5">
<button class="btn btn-secondary" (click)="undoLastAction()">UNDO</button>
+ <button class="btn btn-info" (click)="changeSectionTo('QR Code');">
+ Create QR Code
+ </button>
<button class="btn btn-warning" (click)="submit2024Actions();">
Submit
</button>
@@ -489,6 +491,75 @@
</button>
</div>
</div>
+ <div *ngSwitchCase="'QR Code'" id="QR Code" class="container-fluid">
+ <span>Density:</span>
+ <select
+ [(ngModel)]="qrCodeValuePieceSize"
+ (ngModelChange)="updateQrCodeValuePieceSize()"
+ type="number"
+ id="qr_code_piece_size"
+ >
+ <option
+ *ngFor="let pieceSize of QR_CODE_PIECE_SIZES"
+ [ngValue]="pieceSize"
+ >
+ {{pieceSize}}
+ </option>
+ </select>
+ <div class="qr-container">
+ <qrcode
+ [qrdata]="qrCodeValuePieces[qrCodeValueIndex]"
+ [width]="1000"
+ [errorCorrectionLevel]="'M'"
+ [margin]="6"
+ class="qrcode"
+ ></qrcode>
+ </div>
+ <nav class="qrcode-nav">
+ <ul
+ class="qrcode-buttons pagination pagination-lg justify-content-center"
+ >
+ <li class="page-item">
+ <a
+ class="page-link"
+ href="#"
+ aria-label="Previous"
+ (click)="setQrCodeValueIndex(qrCodeValueIndex - 1)"
+ >
+ <span aria-hidden="true">«</span>
+ <span class="visually-hidden">Previous</span>
+ </a>
+ </li>
+ <li *ngFor="let _ of qrCodeValuePieces; index as i" class="page-item">
+ <a
+ class="page-link"
+ href="#"
+ (click)="setQrCodeValueIndex(i)"
+ [class.active]="qrCodeValueIndex == i"
+ >
+ {{i + 1}}
+ </a>
+ </li>
+ <li class="page-item">
+ <a
+ class="page-link"
+ href="#"
+ aria-label="Next"
+ (click)="setQrCodeValueIndex(qrCodeValueIndex + 1)"
+ >
+ <span aria-hidden="true">»</span>
+ <span class="visually-hidden">Next</span>
+ </a>
+ </li>
+ </ul>
+ </nav>
+ <button
+ class="btn btn-secondary"
+ (click)="changeSectionTo('Review and Submit')"
+ >
+ BACK
+ </button>
+ </div>
<span class="progress_message" role="alert">{{ progressMessage }}</span>
<span class="error_message" role="alert">{{ errorMessage }}</span>
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index d37ce10..a02c0a6 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -2,6 +2,8 @@
"name": "@org_frc971/scouting/www/entry",
"private": true,
"dependencies": {
+ "pako": "2.1.0",
+ "@types/pako": "2.0.3",
"@org_frc971/scouting/www/counter_button": "workspace:*",
"@angular/forms": "v16-lts"
}
diff --git a/scouting/www/index.html b/scouting/www/index.html
index 46779cc..821acf2 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -4,6 +4,8 @@
<meta charset="utf-8" />
<title>FRC971 Scouting Application</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <link rel="manifest" href="/manifest.json" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
@@ -16,8 +18,17 @@
integrity="d8824f7067cdfea38afec7e9ffaf072125266824206d69ef1f112d72153a505e"
/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
+ <script>
+ // In order to hook into WASM's "finished loading" event, we interact
+ // with the global Module variable. Maybe some day there'll be a better
+ // way to interact with it.
+ var Module = {};
+ </script>
</head>
<body>
<my-app></my-app>
+ <noscript>
+ Please enable JavaScript to continue using this application.
+ </noscript>
</body>
</html>
diff --git a/scouting/www/manifest.json b/scouting/www/manifest.json
new file mode 100644
index 0000000..9366399
--- /dev/null
+++ b/scouting/www/manifest.json
@@ -0,0 +1,17 @@
+{
+ "name": "FRC971 Scouting App",
+ "short_name": "scouting",
+ "theme_color": "#1976d2",
+ "background_color": "#fafafa",
+ "display": "standalone",
+ "scope": "./",
+ "start_url": "./",
+ "icons": [
+ {
+ "src": "assets/971_144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "purpose": "maskable any"
+ }
+ ]
+}
diff --git a/scouting/www/ngsw-config.json b/scouting/www/ngsw-config.json
new file mode 100644
index 0000000..78d921d
--- /dev/null
+++ b/scouting/www/ngsw-config.json
@@ -0,0 +1,32 @@
+{
+ "$schema": "./node_modules/@angular/service-worker/config/schema.json",
+ "index": "/index.html",
+ "assetGroups": [
+ {
+ "name": "app",
+ "installMode": "prefetch",
+ "resources": {
+ "files": [
+ "/favicon.ico",
+ "/index.html",
+ "/manifest.json",
+ "/*.css",
+ "/bundle-*/*.js",
+ "/*.js"
+ ]
+ }
+ },
+ {
+ "name": "assets",
+ "installMode": "lazy",
+ "updateMode": "prefetch",
+ "resources": {
+ "files": [
+ "/assets/**",
+ "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
+ ]
+ }
+ }
+ ]
+ }
+
diff --git a/scouting/www/ngsw_config_wrapper.js b/scouting/www/ngsw_config_wrapper.js
new file mode 100644
index 0000000..0bc7f19
--- /dev/null
+++ b/scouting/www/ngsw_config_wrapper.js
@@ -0,0 +1,37 @@
+const fs = require('fs');
+const path = require('path');
+const {spawnSync} = require('child_process');
+
+const output_dir = path.join(
+ process.env.BAZEL_BINDIR,
+ process.env.BAZEL_PACKAGE
+);
+console.log(output_dir);
+console.log(process.argv[2]);
+console.log(process.cwd());
+const ngsw_config = process.argv[2];
+console.log(`Trying to run ${ngsw_config} ${process.argv.slice(4).join(' ')}`);
+const result = spawnSync(ngsw_config, process.argv.slice(4), {
+ stdout: 'inherit',
+ stderr: 'inherit',
+});
+
+if (result.status || result.error || result.signal) {
+ console.log("Failed to run 'ngsw_config'");
+ console.log(`status: ${result.status}`);
+ console.log(`error: ${result.error}`);
+ console.log(`signal: ${result.signal}`);
+ console.log(`stdout: ${result.stdout}`);
+ console.log(`stderr: ${result.stderr}`);
+ process.exit(1);
+}
+
+const currentDirectory = process.cwd();
+
+// Read the contents of the current directory
+console.log(`Contents of the current directory: ${currentDirectory}`);
+fs.readdirSync(currentDirectory).forEach((file) => {
+ console.log(file);
+});
+
+fs.copyFileSync(path.join(process.argv[4], 'ngsw.json'), process.argv[3]);
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index 3848c22..431ec71 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -56,8 +56,8 @@
const KEYWORD_CHECKBOX_LABELS = {
goodDriving: 'Good Driving',
badDriving: 'Bad Driving',
- solidPlacing: 'Solid Placing',
- sketchyPlacing: 'Sketchy Placing',
+ solidPlacing: 'Solid Shooting',
+ sketchyPlacing: 'Sketchy Shooting',
goodDefense: 'Good Defense',
badDefense: 'Bad Defense',
easilyDefended: 'Easily Defended',
diff --git a/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
index d1367ea..592735c 100644
--- a/scouting/www/rpc/BUILD
+++ b/scouting/www/rpc/BUILD
@@ -10,6 +10,7 @@
],
generate_public_api = False,
deps = [
+ "//:node_modules/dexie",
"//scouting/webserver/requests/messages:error_response_ts_fbs",
"//scouting/webserver/requests/messages:request_2024_data_scouting_response_ts_fbs",
"//scouting/webserver/requests/messages:request_2024_data_scouting_ts_fbs",
diff --git a/scouting/www/rpc/db.ts b/scouting/www/rpc/db.ts
new file mode 100644
index 0000000..789ac0e
--- /dev/null
+++ b/scouting/www/rpc/db.ts
@@ -0,0 +1,18 @@
+import Dexie, {Table} from 'dexie';
+
+export interface MatchListData {
+ id?: number;
+ data: Uint8Array;
+}
+
+export class AppDB extends Dexie {
+ matchListData!: Table<MatchListData, number>;
+
+ constructor() {
+ super('ngdexieliveQuery');
+ this.version(1).stores({
+ matchListData: 'id,data',
+ });
+ }
+}
+export const db = new AppDB();
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
index fa2dcbd..0a812ce 100644
--- a/scouting/www/rpc/match_list_requestor.ts
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -6,78 +6,82 @@
Match,
RequestAllMatchesResponse,
} from '../../webserver/requests/messages/request_all_matches_response_generated';
-
+import {db, MatchListData} from './db';
const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
-
@Injectable({providedIn: 'root'})
export class MatchListRequestor {
async fetchMatchList(): Promise<Match[]> {
const builder = new Builder();
RequestAllMatches.startRequestAllMatches(builder);
builder.finish(RequestAllMatches.endRequestAllMatches(builder));
-
const buffer = builder.asUint8Array();
const res = await fetch('/requests/request/all_matches', {
method: 'POST',
body: buffer,
});
-
if (res.ok) {
const resBuffer = await res.arrayBuffer();
- const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
- const parsedResponse =
- RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
-
- // Convert the flatbuffer list into an array. That's more useful.
- const matchList = [];
- for (let i = 0; i < parsedResponse.matchListLength(); i++) {
- matchList.push(parsedResponse.matchList(i));
- }
-
- // Sort the list so it is in chronological order.
- matchList.sort((a, b) => {
- // First sort by match type. E.g. finals are last.
- const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
- const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
- if (aMatchTypeIndex < bMatchTypeIndex) {
- return -1;
- }
- if (aMatchTypeIndex > bMatchTypeIndex) {
- return 1;
- }
- // Then sort by match number. E.g. in semi finals, all match 1 rounds
- // are done first. Then come match 2 rounds. And then, if necessary,
- // the match 3 rounds.
- const aMatchNumber = a.matchNumber();
- const bMatchNumber = b.matchNumber();
- if (aMatchNumber < bMatchNumber) {
- return -1;
- }
- if (aMatchNumber > bMatchNumber) {
- return 1;
- }
- // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
- // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
- // Semi Final 2 Match 2.
- const aSetNumber = a.setNumber();
- const bSetNumber = b.setNumber();
- if (aSetNumber < bSetNumber) {
- return -1;
- }
- if (aSetNumber > bSetNumber) {
- return 1;
- }
- return 0;
- });
-
- return matchList;
+ const u8Buffer = new Uint8Array(resBuffer);
+ // Cache the response.
+ await db.matchListData.put({id: 1, data: u8Buffer});
+ return this.parseMatchList(u8Buffer);
} else {
+ const cachedResult = await db.matchListData.where({id: 1}).toArray();
+ if (cachedResult && cachedResult.length == 1) {
+ const u8Buffer = cachedResult[0].data;
+ return this.parseMatchList(u8Buffer);
+ }
const resBuffer = await res.arrayBuffer();
const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
-
const errorMessage = parsedResponse.errorMessage();
throw `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
}
}
+ parseMatchList(u8Buffer: Uint8Array): Match[] {
+ const fbBuffer = new ByteBuffer(u8Buffer);
+ const parsedResponse =
+ RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
+ // Convert the flatbuffer list into an array. That's more useful.
+ const matchList = [];
+ for (let i = 0; i < parsedResponse.matchListLength(); i++) {
+ matchList.push(parsedResponse.matchList(i));
+ }
+ // Sort the list so it is in chronological order.
+ matchList.sort((a, b) => {
+ // First sort by match type. E.g. finals are last.
+ const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
+ const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
+ if (aMatchTypeIndex < bMatchTypeIndex) {
+ return -1;
+ }
+ if (aMatchTypeIndex > bMatchTypeIndex) {
+ return 1;
+ }
+ // Then sort by match number. E.g. in semi finals, all match 1 rounds
+ // are done first. Then come match 2 rounds. And then, if necessary,
+ // the match 3 rounds.
+ const aMatchNumber = a.matchNumber();
+ const bMatchNumber = b.matchNumber();
+ if (aMatchNumber < bMatchNumber) {
+ return -1;
+ }
+ if (aMatchNumber > bMatchNumber) {
+ return 1;
+ }
+ // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
+ // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
+ // Semi Final 2 Match 2.
+ const aSetNumber = a.setNumber();
+ const bSetNumber = b.setNumber();
+ if (aSetNumber < bSetNumber) {
+ return -1;
+ }
+ if (aSetNumber > bSetNumber) {
+ return 1;
+ }
+ return 0;
+ });
+ return matchList;
+ }
}
diff --git a/scouting/www/scan/BUILD b/scouting/www/scan/BUILD
new file mode 100644
index 0000000..88b2822
--- /dev/null
+++ b/scouting/www/scan/BUILD
@@ -0,0 +1,18 @@
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
+
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
+ name = "scan",
+ extra_srcs = [
+ "//scouting/www:app_common_css",
+ ],
+ deps = [
+ ":node_modules/@angular/forms",
+ ":node_modules/@types/pako",
+ ":node_modules/pako",
+ "//scouting/webserver/requests/messages:error_response_ts_fbs",
+ "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+ ],
+)
diff --git a/scouting/www/scan/package.json b/scouting/www/scan/package.json
new file mode 100644
index 0000000..a5950c3
--- /dev/null
+++ b/scouting/www/scan/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@org_frc971/scouting/www/scan",
+ "private": true,
+ "dependencies": {
+ "pako": "2.1.0",
+ "@types/pako": "2.0.3",
+ "@angular/forms": "v16-lts"
+ }
+}
diff --git a/scouting/www/scan/scan.component.css b/scouting/www/scan/scan.component.css
new file mode 100644
index 0000000..11cfe09
--- /dev/null
+++ b/scouting/www/scan/scan.component.css
@@ -0,0 +1,20 @@
+video {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+}
+
+canvas {
+ /* We don't want to show the frames that we are scanning for QR codes. It's
+ * nicer to just see the video stream. */
+ display: none;
+}
+
+ul {
+ margin: 0px;
+}
+
+li > a.active {
+ /* Set the scanned QR codes to a green color. */
+ background-color: #198754;
+ border-color: #005700;
+}
diff --git a/scouting/www/scan/scan.component.ts b/scouting/www/scan/scan.component.ts
new file mode 100644
index 0000000..98a7b61
--- /dev/null
+++ b/scouting/www/scan/scan.component.ts
@@ -0,0 +1,236 @@
+import {Component, NgZone, OnInit, ViewChild, ElementRef} from '@angular/core';
+import {ErrorResponse} from '../../webserver/requests/messages/error_response_generated';
+import {Builder, ByteBuffer} from 'flatbuffers';
+import * as pako from 'pako';
+
+declare var cv: any;
+declare var Module: any;
+
+// The number of milliseconds between QR code scans.
+const SCAN_PERIOD = 500;
+
+@Component({
+ selector: 'app-scan',
+ templateUrl: './scan.ng.html',
+ styleUrls: ['../app/common.css', './scan.component.css'],
+})
+export class ScanComponent implements OnInit {
+ @ViewChild('video')
+ public video: ElementRef;
+
+ @ViewChild('canvas')
+ public canvas: ElementRef;
+
+ errorMessage: string = '';
+ progressMessage: string = 'Waiting for QR code(s)';
+ scanComplete: boolean = false;
+ videoStartedSuccessfully = false;
+
+ qrCodeValuePieces: string[] = [];
+ qrCodeValuePieceSize = 0;
+
+ scanStream: MediaStream | null = null;
+ scanTimer: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(private ngZone: NgZone) {}
+
+ ngOnInit() {
+ // If the user switched away from this tab, then the onRuntimeInitialized
+ // attribute will already be set. No need to load OpenCV again. If it's not
+ // loaded, however, we need to load it.
+ if (!Module['onRuntimeInitialized']) {
+ Module['onRuntimeInitialized'] = () => {
+ // Since the WASM code doesn't know about the Angular zone, we force
+ // it into the correct zone so that the UI gets updated properly.
+ this.ngZone.run(() => {
+ this.startScanning();
+ });
+ };
+ // Now that we set up the hook, we can load OpenCV.
+ this.loadOpenCv();
+ } else {
+ this.startScanning();
+ }
+ }
+
+ ngOnDestroy() {
+ clearInterval(this.scanTimer);
+
+ // Explicitly stop the streams so that the camera isn't being locked
+ // unnecessarily. I.e. other processes can use it too.
+ if (this.scanStream) {
+ this.scanStream.getTracks().forEach((track) => {
+ track.stop();
+ });
+ }
+ }
+
+ public ngAfterViewInit() {
+ // Start the video playback.
+ // It would be nice to let the user select which camera gets used. For now,
+ // we give the "environment" hint so that it faces away from the user.
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+ navigator.mediaDevices
+ .getUserMedia({video: {facingMode: 'environment'}})
+ .then(
+ (stream) => {
+ this.scanStream = stream;
+ this.video.nativeElement.srcObject = stream;
+ this.video.nativeElement.play();
+ this.videoStartedSuccessfully = true;
+ },
+ (reason) => {
+ this.progressMessage = '';
+ this.errorMessage = `Failed to start video: ${reason}`;
+ }
+ );
+ }
+ }
+
+ async scan() {
+ if (!this.videoStartedSuccessfully) {
+ return;
+ }
+
+ // Take a capture of the video stream. That capture can then be used by
+ // OpenCV to perform the QR code detection. Due to my inexperience, I could
+ // only make this code work if I size the (invisible) canvas to match the
+ // video element. Otherwise, I'd get cropped images.
+ // Can we stream the video directly into the canvas?
+ const width = this.video.nativeElement.clientWidth;
+ const height = this.video.nativeElement.clientHeight;
+ this.canvas.nativeElement.width = width;
+ this.canvas.nativeElement.height = height;
+ this.canvas.nativeElement
+ .getContext('2d')
+ .drawImage(this.video.nativeElement, 0, 0, width, height);
+
+ // Perform the QR code detection. We use the Aruco-variant of the detector
+ // here because it appears to detect QR codes much more reliably than the
+ // standard detector.
+ let mat = cv.imread('canvas');
+ let qrDecoder = new cv.QRCodeDetectorAruco();
+ const result = qrDecoder.detectAndDecode(mat);
+ mat.delete();
+
+ // Handle the result.
+ if (result) {
+ await this.scanSuccessHandler(result);
+ } else {
+ await this.scanFailureHandler();
+ }
+ }
+
+ async scanSuccessHandler(scanResult: string) {
+ // Reverse the conversion and obtain the original Uint8Array. In other
+ // words, undo the work in `scouting/www/entry/entry.component.ts`.
+ const [indexStr, numPiecesStr, pieceSizeStr, splitPiece] = scanResult.split(
+ '_',
+ 4
+ );
+
+ // If we didn't get enough data, then maybe we scanned some non-scouting
+ // related QR code? Try to give a hint to the user.
+ if (!indexStr || !numPiecesStr || !pieceSizeStr || !splitPiece) {
+ this.progressMessage = '';
+ this.errorMessage = `Couldn't find scouting data in the QR code.`;
+ return;
+ }
+
+ const index = Number(indexStr);
+ const numPieces = Number(numPiecesStr);
+ const pieceSize = Number(pieceSizeStr);
+
+ if (
+ numPieces != this.qrCodeValuePieces.length ||
+ pieceSize != this.qrCodeValuePieceSize
+ ) {
+ // The number of pieces or the piece size changed. We need to reset our accounting.
+ this.qrCodeValuePieces = new Array<string>(numPieces);
+ this.qrCodeValuePieceSize = pieceSize;
+ }
+
+ this.qrCodeValuePieces[index] = splitPiece;
+ this.progressMessage = `Scanned QR code ${index + 1} out of ${
+ this.qrCodeValuePieces.length
+ }`;
+
+ // Count up the number of missing pieces so we can give a progress update.
+ let numMissingPieces = 0;
+ for (const piece of this.qrCodeValuePieces) {
+ if (!piece) {
+ numMissingPieces++;
+ }
+ }
+ if (numMissingPieces > 0) {
+ this.progressMessage = `Waiting for ${numMissingPieces} out of ${this.qrCodeValuePieces.length} QR codes.`;
+ this.errorMessage = '';
+ return;
+ }
+
+ // Stop scanning now that we have all the pieces.
+ this.progressMessage = 'Scanned all QR codes. Submitting.';
+ this.scanComplete = true;
+ clearInterval(this.scanTimer);
+
+ const encodedData = this.qrCodeValuePieces.join('');
+ const deflatedData = Uint8Array.from(atob(encodedData), (c) =>
+ c.charCodeAt(0)
+ );
+ const actionBuffer = pako.inflate(deflatedData);
+
+ const res = await fetch('/requests/submit/submit_2024_actions', {
+ method: 'POST',
+ body: actionBuffer,
+ });
+
+ if (res.ok) {
+ // We successfully submitted the data. Report success.
+ this.progressMessage = 'Success!';
+ this.errorMessage = '';
+ } else {
+ const resBuffer = await res.arrayBuffer();
+ const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+ const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+
+ const errorMessage = parsedResponse.errorMessage();
+ this.progressMessage = '';
+ this.errorMessage = `Submission failed with ${res.status} ${res.statusText}: "${errorMessage}"`;
+ }
+ }
+
+ async scanFailureHandler() {
+ this.progressMessage = '';
+ this.errorMessage = 'Failed to scan!';
+ }
+
+ loadOpenCv() {
+ // Make the browser load OpenCV.
+ let body = <HTMLDivElement>document.body;
+ let script = document.createElement('script');
+ script.innerHTML = '';
+ script.src = 'assets/opencv_4.9.0/opencv.js';
+ script.async = false;
+ script.defer = true;
+ script.onerror = (error) => {
+ this.progressMessage = '';
+ if (typeof error === 'string') {
+ this.errorMessage = `OpenCV failed to load: ${error}`;
+ } else {
+ this.errorMessage = 'OpenCV failed to load.';
+ }
+ // Since we use the onRuntimeInitialized property as a flag to see if we
+ // need to perform loading, we need to delete the property. When the user
+ // switches away from this tab and then switches back, we want to attempt
+ // loading again.
+ delete Module['onRuntimeInitialized'];
+ };
+ body.appendChild(script);
+ }
+
+ startScanning() {
+ this.scanTimer = setInterval(() => {
+ this.scan();
+ }, SCAN_PERIOD);
+ }
+}
diff --git a/scouting/www/scan/scan.module.ts b/scouting/www/scan/scan.module.ts
new file mode 100644
index 0000000..00a9c58
--- /dev/null
+++ b/scouting/www/scan/scan.module.ts
@@ -0,0 +1,10 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {ScanComponent} from './scan.component';
+
+@NgModule({
+ declarations: [ScanComponent],
+ exports: [ScanComponent],
+ imports: [CommonModule],
+})
+export class ScanModule {}
diff --git a/scouting/www/scan/scan.ng.html b/scouting/www/scan/scan.ng.html
new file mode 100644
index 0000000..f9b82b3
--- /dev/null
+++ b/scouting/www/scan/scan.ng.html
@@ -0,0 +1,21 @@
+<h1>Scan</h1>
+<span class="progress_message" role="alert">{{ progressMessage }}</span>
+<span class="error_message" role="alert">{{ errorMessage }}</span>
+<nav class="qrcode-progress" *ngIf="!scanComplete">
+ <ul class="pagination pagination-lg justify-content-center">
+ <li *ngFor="let piece of qrCodeValuePieces" class="page-item">
+ <a class="page-link" href="#" [class.active]="piece">
+ <i *ngIf="piece" class="bi bi-check">
+ <span class="visually-hidden">✓</span>
+ </i>
+ <i *ngIf="!piece" class="bi bi-camera">
+ <span class="visually-hidden">☒</span>
+ </i>
+ </a>
+ </li>
+ </ul>
+</nav>
+<div *ngIf="!scanComplete">
+ <video #video id="video"></video>
+</div>
+<canvas #canvas id="canvas"></canvas>
diff --git a/tools/build_rules/js.bzl b/tools/build_rules/js.bzl
index 51fe987..c2468f8 100644
--- a/tools/build_rules/js.bzl
+++ b/tools/build_rules/js.bzl
@@ -378,7 +378,7 @@
copy_file(
name = name + "_config",
- out = "cypress.config.js",
+ out = name + "_cypress.config.js",
src = "//tools/build_rules/js:cypress.config.js",
visibility = ["//visibility:private"],
)
@@ -392,7 +392,7 @@
name = name,
args = [
"run",
- "--config-file=cypress.config.js",
+ "--config-file=%s_cypress.config.js" % name,
"--browser=" + chrome_location,
],
browsers = ["@chrome_linux//:all"],
diff --git a/tools/build_rules/js/BUILD b/tools/build_rules/js/BUILD
index 1bbe769..86a0288 100644
--- a/tools/build_rules/js/BUILD
+++ b/tools/build_rules/js/BUILD
@@ -24,3 +24,11 @@
"//:node_modules/@types/node",
],
)
+
+py_binary(
+ name = "assemble_service_worker_files",
+ srcs = [
+ "assemble_service_worker_files.py",
+ ],
+ visibility = ["//visibility:public"],
+)
diff --git a/tools/build_rules/js/assemble_service_worker_files.py b/tools/build_rules/js/assemble_service_worker_files.py
new file mode 100644
index 0000000..d4485d5
--- /dev/null
+++ b/tools/build_rules/js/assemble_service_worker_files.py
@@ -0,0 +1,27 @@
+import argparse
+import shutil
+import sys
+from pathlib import Path
+
+
+def main(argv):
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--input_dir", type=Path, action="append", default=[])
+ parser.add_argument("--output", type=Path, action="append", default=[])
+ parser.add_argument("--relative_output",
+ type=Path,
+ action="append",
+ default=[])
+ args = parser.parse_args(argv[1:])
+
+ for relative_output, output in zip(args.relative_output, args.output):
+ for input_dir in args.input_dir:
+ input_file = input_dir / relative_output
+ if input_file.exists():
+ print(f"Copying {input_file} to {output}")
+ shutil.copy(input_file, output)
+ break
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/tools/build_rules/js/cypress.config.js b/tools/build_rules/js/cypress.config.js
index e991016..5d2ec1c 100644
--- a/tools/build_rules/js/cypress.config.js
+++ b/tools/build_rules/js/cypress.config.js
@@ -9,6 +9,14 @@
launchOptions.args.push('--disable-gpu-shader-disk-cache');
launchOptions.args.push('--enable-logging');
launchOptions.args.push('--v=stderr');
+
+ // Point the browser at a video file to use as a webcam. This lets us
+ // validate things like QR code scanning.
+ launchOptions.args.push('--use-fake-ui-for-media-stream');
+ launchOptions.args.push('--use-fake-device-for-media-stream');
+ const fakeCameraVideo = `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/fake_camera.mjpeg`;
+ launchOptions.args.push(`--use-file-for-fake-video-capture=${fakeCameraVideo}`);
+
return launchOptions;
});
diff --git a/tools/dependency_rewrite b/tools/dependency_rewrite
index 1cd117a..14980dd 100644
--- a/tools/dependency_rewrite
+++ b/tools/dependency_rewrite
@@ -18,6 +18,7 @@
rewrite cdn.cypress.io/(.*) software.frc971.org/Build-Dependencies/cdn.cypress.io/$1
rewrite www.googleapis.com/(.*) software.frc971.org/Build-Dependencies/www.googleapis.com/$1
rewrite www.johnvansickle.com/(.*) software.frc971.org/Build-Dependencies/www.johnvansickle.com/$1
+rewrite docs.opencv.org/(.*) software.frc971.org/Build-Dependencies/docs.opencv.org/$1
allow crates.io
allow golang.org
allow go.dev