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">&laquo;</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">&raquo;</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">&check;</span>
+        </i>
+        <i *ngIf="!piece" class="bi bi-camera">
+          <span class="visually-hidden">&#9746;</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