Merge "Update Notes Checkboxes"
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..e111de3 100644
--- a/frc971/orin/apriltag_detect.cc
+++ b/frc971/orin/apriltag_detect.cc
@@ -302,7 +302,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;
diff --git a/package.json b/package.json
index 8f05d0f..b8fb10b 100644
--- a/package.json
+++ b/package.json
@@ -17,8 +17,11 @@
"@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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d44c82a..13df2c5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,12 +56,21 @@
'@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
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 +111,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 +141,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':
@@ -996,6 +1023,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
@@ -1074,6 +1104,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'}
@@ -1324,6 +1364,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 +1467,14 @@
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@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1608,6 +1661,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 +1691,10 @@
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
dev: true
+ /dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+ dev: true
+
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: true
@@ -1656,6 +1718,10 @@
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
dev: true
+ /encode-utf8@1.0.3:
+ resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==}
+ dev: true
+
/encoding@0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
requiresBuild: true
@@ -1786,6 +1852,14 @@
to-regex-range: 5.0.1
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
+
/foreground-child@3.1.1:
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
engines: {node: '>=14'}
@@ -2363,6 +2437,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
@@ -2801,6 +2882,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 +2903,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 +2936,18 @@
- 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
+ /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 +2997,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 +3060,17 @@
engines: {node: '>=6'}
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'}
@@ -3012,6 +3136,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'}
@@ -3576,6 +3704,10 @@
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 +3761,10 @@
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
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 +3778,36 @@
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@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@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/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..b46f265 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -5,8 +5,19 @@
npm_link_all_packages(name = "node_modules")
+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 = [
+ ":opencv.js",
+ ],
extra_srcs = [
"app/common.css",
],
@@ -20,6 +31,7 @@
"//scouting/www/match_list",
"//scouting/www/notes",
"//scouting/www/pit_scouting",
+ "//scouting/www/scan",
"//scouting/www/shift_schedule",
"//scouting/www/view",
],
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
index bf393e4..ccc4840 100644
--- a/scouting/www/app/app.module.ts
+++ b/scouting/www/app/app.module.ts
@@ -10,6 +10,7 @@
import {ViewModule} from '../view';
import {DriverRankingModule} from '../driver_ranking';
import {PitScoutingModule} from '../pit_scouting';
+import {ScanModule} from '../scan';
@NgModule({
declarations: [App],
@@ -23,6 +24,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..895336b 100644
--- a/scouting/www/app/app.ts
+++ b/scouting/www/app/app.ts
@@ -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;
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 6da8be8..09b1a8b 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;
@@ -136,6 +146,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() {
@@ -219,7 +239,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;
}
@@ -282,6 +303,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;
}
@@ -291,7 +317,7 @@
this.header.nativeElement.scrollIntoView();
}
- async submit2024Actions() {
+ createActionsBuffer() {
const builder = new Builder();
const actionOffsets: number[] = [];
@@ -419,10 +445,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 22d2d27..139268f 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -476,6 +476,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>
@@ -484,6 +487,75 @@
<div *ngSwitchCase="'Success'" id="Success" class="container-fluid">
<h2>Successfully submitted data.</h2>
</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..e4e996a 100644
--- a/scouting/www/index.html
+++ b/scouting/www/index.html
@@ -16,6 +16,12 @@
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>
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/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