An independent native macOS application for the Apperson DataLink 1200 optical mark scanner — the bubble-sheet reader sitting in a lot of school supply closets. It is not affiliated with or endorsed by Apperson, and ships none of their software.
Plug the scanner in, pick your class, and feed sheets. The app puts the scanner into Data Collection mode and reads a complete record for every sheet — the bubbled student ID and every response — matching each one to a student as it goes. Sessions are saved locally and can be reviewed and exported to CSV whenever you need them.
Because every answer is captured per question and not just a total score, the app can score the session and write a full item analysis — which questions the class missed, which distractors pulled them away, and how well each item discriminated. That is the part worth having: it turns a stack of graded sheets into a short list of concepts to reteach, backed by where the learning gaps actually are.
The DataLink 1200 is still a perfectly good scanner. What it lost was its software: Apperson's DataLink Connect download no longer offers a supported macOS build. So a working piece of classroom hardware became unusable on a Mac, with no way to get results off it short of keeping a Windows machine around for that one task.
The scanner itself never stopped working. It presents as an ordinary USB serial device, and it still speaks its protocol perfectly well — there was simply nothing left on macOS to speak it to.
So this project recovered the protocol from USBPcap captures of DataLink Connect driving the scanner on Windows, and reimplemented the useful half of it natively for macOS. No vendor software is used or redistributed; see docs/PROTOCOL.md for what is confirmed on the wire versus still inferred, and docs/TESTING.md for the experiment log that got there.
Privacy: everything runs locally. No student ID, response, key, or score is ever sent to a network service. The server binds to loopback only, and the data stays in a folder on your Mac that you control.
The same codebase builds two applications. They are not forks, and there is no second branch: which edition a build is is decided at runtime, by whether macOS has put the app in a sandbox container.
# src/datalink_scanner/edition.py
def sandboxed() -> bool:
return "/Library/Containers/" in str(Path.home())A build flag can be set wrong. This cannot.
| Homebrew build | Mac App Store build | |
|---|---|---|
| Install | brew install dhohnholt/datalink/datalink-scanner |
the App Store |
| Updates | brew upgrade, or the Updates card in Settings |
the App Store, and nothing else |
| Sandbox | no | yes, with the scanner reachable through com.apple.security.device.serial |
| Signing | ad-hoc, built on your Mac by Homebrew | Apple Distribution, notarized by App Review |
| Data | ~/Library/Application Support/DataLink Scanner |
the app's own container |
| Keychain | through a helper, because an ad-hoc signature changes every upgrade | read in process; the signature is stable |
| Paper scanning | installs OpenCV, NumPy and Pillow on first use | already in the bundle |
Differences between the editions are configuration and capability, never a
fork. Every one of them is a branch on edition.sandboxed() in the same
file as the behaviour it changes, so the two cannot drift apart the way two
long-lived branches would. Adding a second branch would mean landing every
scanner fix, parser fix and interface fix twice, forever, for a difference that
amounts to what the sandbox forbids.
What the sandbox forbids, and what the store edition therefore does not do:
update itself, move other copies of the app to the Trash, edit the
LaunchServices database, install packages at runtime, write out and run a
helper binary, or open a file it was not handed by an open panel.
tests/test_edition.py asserts each of those, and asserts that the Homebrew
edition keeps all of them.
Sending a scored report is off in both until a teacher enters the address of their own site, in Settings. Nothing is sent anywhere by default, and no address is written into this repository. The store edition also carries the full specification of what the app sends and what it expects back, so someone with no site yet can have one built.
Separate signatures, separate containers, separate data. Installing one does not upgrade or replace the other, and sessions scanned in one are not visible in the other. Their version numbers move independently: a Homebrew release is a script you run, and a store release waits on App Review.
brew install dhohnholt/datalink/datalink-scannerThen link the app once so it appears in the Dock and Spotlight:
datalink-scanner install-appIf you previously drag-installed the app from a .dmg, that copy is moved to
the Trash and replaced with the link — otherwise you would keep launching the
old build, which Homebrew cannot update. A different application that happens
to share the name is left alone.
Updating later, including the app:
brew upgrade datalink-scannerOr from inside the app: DataLink Scanner → Check for Updates…. It asks
GitHub what the newest release is and, when there is one, runs the Homebrew
upgrade for you behind a progress sheet — the bar follows Homebrew's own steps
and the line underneath is whatever it is doing at the time — then closes and
reopens itself on the new version. Copies of
the app left over from an earlier disk image or a local build are moved to the
Trash in the same pass, so only the current one is left to launch. Nothing is
deleted, and Homebrew's own storage is left to brew cleanup.
A copy installed from the .dmg cannot replace itself, so there the same menu
item offers the download page instead.
The app also checks once a day when it opens, and says nothing unless there is a newer version — not when it is current, not when the network is down, and never while sheets are going through the feeder. Turn it off under Settings → Updates, which also shows the running version and when it last checked. The only thing sent to GitHub is a request for the latest release number.
The .app is a thin launcher around the datalink-scanner command, so an
upgrade takes effect immediately with nothing to rebuild or re-download. It is
built on your machine rather than downloaded, so macOS does not quarantine it
and there is no "unidentified developer" prompt.
Without Homebrew
Download the .dmg from
Releases
and drag the app to Applications. That build bundles its own Python and is
ad-hoc signed rather than Apple notarized, so the first launch needs a
Control-click → Open. Updates are manual.
It is built for Apple Silicon and runs on macOS 13 or later. An Intel Mac needs the Homebrew install, which builds for whatever Mac it is on.
Unlike the Homebrew install, this build carries everything paper scanning needs — OpenCV, NumPy, Pillow and poppler — so the Paper page works with nothing else to install. That is the build for a managed Mac that cannot reach GitHub or run Homebrew at all. It has no update path, though: a new version means a new disk image.
Or install from source:
git clone https://github.com/dhohnholt/datalink_Mac_OS_interface.git
cd datalink_Mac_OS_interface
python3 -m venv .venv && .venv/bin/pip install -e .
.venv/bin/datalink-scannerThe DataLink 1200 is a Silicon Labs CP210x USB serial device
(VID:PID 10c4:ea60). Check that macOS sees it:
datalink-scanner portsIf nothing is listed, install the Silicon Labs CP210x VCP driver and reconnect the scanner.
Open DataLink Scanner from the Dock, Spotlight or Applications — or run
datalink-scanner. It opens its own window with a normal macOS menu bar. No
browser window, no terminal window.
The window has six pages, on the tab bar and under the View menu: Scan (⌘1), Paper (⌘2), Classes (⌘3), Sessions (⌘4), Analysis (⌘5) and Settings (⌘6).
- Enter a test name — it becomes the exported CSV's filename.
- Pick the class from the same row, and type the questions per form (1–100, default 50). Set the real form length: the count decides how many answer slots are read, so questions a student left blank at the end stay blank instead of being trimmed away.
- Choose how sheets are matched to students (see below).
- Select Connect and start session (⌘K), and wait for Ready to scan.
- Feed the answer key first, then the student sheets, one at a time.
- Select End session (⇧⌘K) when the class is done. The sheets are filed under Sessions, and the scanner stays connected, so the next class only needs a new test name and Start scanning session.
- Export CSV when you're done — it opens a normal Save panel. ⌘Q quits.
A session owns its answer key, its sheet numbering and its own file. Nothing is recorded while no session is running, and each one begins by expecting a key.
The DataLink can stop part-way through a form — most often waiting for the other side of a two-sided one. The Activity list now says so in plain words rather than filing it with the protocol chatter, and Reset scanner re-sends the handshake to clear it without reaching for the device. Re-feed the sheet that jammed afterwards; it was never recorded.
The test name, class, form length and matching mode are remembered between launches.
The reader will not score anything against a key it could not make out, and it says which questions stopped it — two marks on one, or none at all. The Paper tab now asks for those answers instead of leaving the batch stranded: Look at the key sheet opens the page as it was scanned, you pick the right answer for each question, and Use these and read the batch runs it again.
The second run is quick, because the pages are already rendered and cached.
The DataLink marks every sheet against a key held inside it and prints the score on the paper. That number arrives with the record, and the app keeps it.
It is a second opinion on the same bubbles from different hardware, so where it and the app's own scoring disagree, one of them read a mark the other did not — and that sheet appears in Review. The app's score is the one recorded; the scanner's is only ever a reason to look.
Two cases are deliberately quiet. A device with no key loaded marks every sheet zero, which is written exactly like a sheet that scored nothing, so a batch of zeros is ignored rather than turned into a warning against every student. And if the key sheet does not mark full against itself, the device is holding a different key from the one you fed — that is said once, about the device, instead of once per student.
A warning is worked out from the marks every time a test is scored, so leaving a row on Keep as read never made it go away. Tick Checked on anything you have looked at and are happy with, then Apply corrections — those rows go for good, and the count on the tab drops with them. Changing an answer counts as checking it: having decided what a mark says, you are not asking to be warned about it again.
Mark all checked ticks the lot, for a list you have been through on paper. Anything checked off can be brought back with Show them again, and what you settled is left out of the report sent to T-TESS as well as the one on screen.
The address bar is the app's memory of where you are, so Back and Forward work and a view can be linked to or bookmarked:
#/scan #/paper #/classes #/settings
#/sessions #/sessions/5
#/analysis #/analysis/5 #/analysis/5/review
A test is chosen once. Open it under Sessions and it is already loaded under Analysis, and the other way round. A green bar under the tabs says which test you are working on and jumps between its sheets and its analysis; it stays put while you scroll a long table. Clear puts it down again.
The Review tab lists every sheet that could not be read cleanly. Each row carries the student ID and the name, both editable, so a sheet whose ID grid was left blank or read wrong can be put right there and the analysis rescored. Typing an ID fills the name from the roster the test was scanned against — and leaves a name you typed yourself alone. Where the ID is one the roster does not have, the name is a dropdown of the class instead: picking a student says the sheet is theirs and takes their roster ID with it.
Names are looked up again every time a session is opened, scored or exported, not only when it was filed. A roster imported or corrected after a batch was read therefore reaches the sheets already saved.
For a batch read from a PDF, the sheet number opens the page it was read from, so the printed name can be read off the paper while it is being typed in. A DataLink session has no such picture — the scanner sends letters, never an image — so the sheet number stays plain text.
Page images live in the paper cache and go when it is purged from Settings. Reading the batch again brings them back.
Student matching decides who a sheet belongs to:
- By bubbled ID — any order. The scanner reads the ID bubbled on the sheet and the name is filled in from the roster afterwards, so sheets can be fed in whatever order they come off the pile. A sheet whose ID is not on the roster stops and asks rather than guessing.
- In roster order — ignore ID area. For sheets where the ID grid was left blank. Students are taken in the order the class list is in, confirming each one, with Skip absent for anyone who is not there.
- Ask for every sheet. Types the ID by hand each time, for a class with no roster saved.
IDs are compared without leading zeros, because the scanner reports the grid exactly as it was bubbled and a roster typed by hand usually is not padded.
A paper batch also records how dark each mark was. The reader treats anything below 0.05 as nothing at all and separately flags two marks of similar weight, but a mark just above that floor is scored with full confidence and never questioned — which is how a stray pencil line or a half-erased answer becomes a grade.
The Review tab now raises those too. Faintness alone is the wrong test: on a real batch, sixteen of eighteen faint marks were one student pressing lightly. So a mark is listed when it is much lighter than the rest of that student's own sheet, and a sheet that is light all the way through gets a single note instead of one per question. On that batch it turned eighteen entries into two marks and one note.
Each one carries the same A–E/Blank picker as any other review item, so it is corrected in place and everything rescores.
This applies to paper batches only. The DataLink reports letters, not mark strengths, so there is nothing to measure — use the Sessions page to correct one of those.
The scanner is accurate but a sheet can still be smudged, and a mark read as blank might not be. On the Sessions page, open a session and click any response in the table — pick the letter the sheet actually shows, or confirm the blank. The row is saved and everything computed from it, including the item analysis and anything sent to T-TESS, follows from the corrected answer.
The answer key can be corrected the same way, or on the Answer key tab of the Analysis page, which rescores every sheet against it.
Every sheet is written to a local SQLite database as it is scanned, so a session is never only on screen. Sheets waiting for live review are saved too; if the session ends or the app restarts, review them under Analysis → Review. The Sessions page lists them newest first and lets you open one to see every response, export it to CSV, rename it, or delete it. Sessions that were opened but never received a sheet are discarded on disconnect.
Sheets are also appended live to a timestamped browser_session_*.jsonl file
as a plain-text belt-and-braces log. Both live in
~/Library/Application Support/DataLink Scanner/captures.
If the scanner is not available, scan the sheets on any document scanner — a ScanSnap, a copier — and read the PDF on the Paper page (⌘2). Choose the file, say which page holds the marked answer key, and the sheets are read and filed as an ordinary session. From there nothing knows the difference: Sessions, Review, Answer key, Item analysis and Send to T-TESS all work on it, and the scoring is the same code that scores a DataLink capture.
The reading itself is the pipeline from the
omr_final project, vendored unchanged in
src/datalink_scanner/vendor/omr/, so a sheet read this way and a sheet read
by that project produce the same numbers. It handles the green Apperson
"AccuScan" A–E form it was calibrated against; other layouts are skipped and
counted rather than guessed at.
It needs OpenCV, NumPy and Pillow — about 51 MB to download, 154 MB on disk —
which are not part of the normal install, because most people only ever use
the device. Install support on that page fetches them in the background
into ~/Library/Application Support/DataLink Scanner/paper-support, outside
the app, so updating the app does not download them again. Rendering also needs
poppler (brew install poppler). A copy installed from the .dmg cannot
install packages for itself; use the Homebrew install for paper scanning.
Pages are rendered at 400 dpi and cached so a batch can be re-read without scanning again, which costs roughly 100 MB per 16-page batch. The Paper page reports the cache and empties it on request, and once it passes 500 MB the app offers to empty it after a run. Only the page images go — sessions, scores and item analyses are kept, and nothing needs the PDF again once a batch has been read.
The Analysis page scores a scanned test and shows where the class actually struggled. Pick a test and you get the headline numbers — students, class average, KR-20, how many sheets need review — then every question sorted into Priority (below 60% correct), Developing (60–69.9%) and Secure (70% and above), with a callout naming the items to reteach first. Filter to any of those buckets, or to items the analyzer flagged, and open Full statistical diagnostics when you want difficulty, point-biserial, upper/lower discrimination and the whole answer distribution. Student scores sit underneath, highest first, with each student's missed questions.
Those cut points are the same ones the omr_final results page uses, so an item that reads as Priority there reads as Priority here.
Four sections sit under the test picker:
- Item analysis — the buckets, the filters and the diagnostics above
- Student scores — highest first, with each student's missed questions
- Review — every sheet the scanner could not read cleanly: a missing student ID, or a question where an erasure left two marks. Type the ID or pick the intended answer and Apply corrections; the saved session is updated and the analysis follows. The tab carries a count when anything is waiting.
- Answer key — the whole key as a grid. Click a letter to change it, Save answer key, and every sheet is rescored against the new key. Changed rows are highlighted until saved, and Undo changes puts them back.
The DataLink reads far more reliably than a document scanner, so Review should usually be empty — but a smudged ID or a half-erased answer still happens, and an answer key can simply be bubbled wrong.
Download JSON on that page — or Export item analysis (⇧⌘E) from a session — writes the full report for upload.
The Send to T-TESS section uploads a scored run straight to the Reteach Center, so there is no file to export and re-upload by hand.
Connect once, under Settings (⌘5). No address ships with the app, in
either edition, so there are two things to give it: the upload address of
the site that receives the reports, and a connection token that site issues.
On a T-TESS site the token comes from Reteaching → Connect DataLink.
Open T-TESS on that card opens the site in your normal browser, and appears
once a site address has been set. The token is held in this Mac's Keychain and nowhere else — not in the
app database, not in a preferences file, not in any log. It travels only as an
Authorization: Bearer header. No password, email, or other account
credential is ever requested; the server works out the teacher from the token.
The rest of this applies to the Homebrew build. The App Store build is signed by one stable identity, so it reads the Keychain in process and needs no helper.
macOS decides which application may read a Keychain item, and for an app that
is not signed with a paid developer certificate it decides that by file
path. Homebrew installs each version under Cellar/datalink-scanner/<version>,
so every upgrade used to look like a stranger and ask for the login password.
The Keychain is therefore read and written through a small copy of the
interpreter kept at a path that upgrades do not touch —
~/Library/Application Support/DataLink Scanner/runtime/keychain-helper — with
the token passed to it down a pipe, never as a command-line argument. Approve
once with Always Allow and updates stop asking. Upgrading the Python it was
copied from rebuilds it, which costs one more approval.
Then pick Course → Section → Unit → Test and upload. The list of available tests is refreshed when you connect, when the app opens, and before an upload once it is more than fifteen minutes old.
If that test already has an unfinalized DataLink upload, the picker shows its date, student count and question count. Sending the corrected scan updates that draft atomically instead of adding another result run. DataLink asks for confirmation first, and the server refuses to replace finalized results.
Uploading does not finalize anything. It creates a run for review; you check the warnings, student-ID matches, blanks and multiple marks on the website and finalize there. The success panel links straight to that review.
Each finalized upload remains an audit record. A retry after a network failure reuses the same local run identifier so it cannot become a second record. While the selected test's current DataLink run is still a draft, correcting a student ID, response, or answer key and sending again replaces that draft; after it is finalized, DataLink creates a new review run instead of overwriting history.
The scoring is not reimplemented here. src/datalink_scanner/vendor/ holds
verbatim copies of analysis_core.py and result_schema.py from the
omr_final project, which scores the same sheets when they are read on a
document scanner instead. Feeding those same functions means a sheet read
either way produces the same report against one schema, which is what the
upload endpoint expects.
Do not edit the vendored copies. Change them in omr_final and copy them across;
tests/test_analysis.py fails if they drift, and separately scores a session
both ways — directly, and by handing the exported CSV to omr_final — and
asserts the two JSON documents are identical.
From a terminal:
datalink-scanner analyze --list # saved sessions and their ids
datalink-scanner analyze 12 # writes "<session name>.json"Scoring needs exactly one answer key and at least one student sheet, and the key itself must have no blank or double-marked questions. The export is greyed out with the reason when a session cannot be scored.
Everything is kept in that one folder. It is user data, not a cache, so macOS does not purge it and Time Machine backs it up — but nothing else prunes it either, so the app does not delete anything on its own.
Growth is slight: about 360 bytes per sheet. A year of six classes of thirty students sitting a fifty-question test every week — around 6,700 sheets — comes to roughly 2.5 MB.
Paper batches are the exception, because their rendered pages are images: around 100 MB per 16-page batch. Those are a cache rather than data — see Paper batches above — and the app offers to empty them once they pass 500 MB.
The Sessions page shows exactly what is stored and where, and Delete sessions older than… removes old sessions, their scans and their log files in one step, then compacts the database. Deleting a single session from the list does the same for that one.
Classes are picked on the Scan page and managed on the Classes page. There,
choose New class, name it, and paste one student
per line as student ID, name. Rosters are stored in the app's database on
that Mac and reused every time you scan. The Scan page names the next student
and offers Skip absent and Start at first student; progress is tracked
per class.
When the scanner reads a valid ID belonging to the selected class, the sheet is
saved immediately with no prompt. A dialog appears only when the ID is missing,
the ID is not in the selected class, or an answer needs review — for example
when an erasure leaves two marks and the scanner reports AC. You can then
pick the intended answer, mark it blank, or keep both marks.
datalink-scanner # open the app window (same as `app`)
datalink-scanner serve # serve the same workspace to a web browser
datalink-scanner ports # list USB serial ports
datalink-scanner scan --acknowledge-writes # capture to JSONL, no window
datalink-scanner replay raw.bin # parse a saved byte stream offline
datalink-scanner install-app # symlink the app into /Applicationsserve is the fallback: same workspace, opened in your browser. Useful if the
window misbehaves, or over SSH.
scan and serve both send commands that change the scanner's mode, so
scan requires --acknowledge-writes to confirm a scanner is actually
attached.
Sessions go to --capture-dir, $DATALINK_CAPTURE_DIR, or the default above —
in that order. In a source checkout the default is ./captures instead.
DirectDataLinkScanner serial I/O: 38400 8N1, DTR off, RTS on
↓
DataLinkStreamParser CRLF framing; separates control replies from records
↓
DataLinkFormRecord one 211-field ASCII CSV record per sheet
↓
ScannerController session state, review queue, JSONL + CSV output
↓
Store SQLite: classes, settings, sessions and every scan
↓
local HTTP workspace loopback only; the UI and its API
↓
Cocoa shell NSWindow + menu bar + WKWebView (or a browser tab)
The Cocoa shell in app.py owns the window, the menu bar, the Save panel and
the alert panels; it hosts the same server in-process on an OS-assigned port.
Menu items drive the very same controls the UI exposes, so there is one code
path per action rather than two.
The scanner emits one CRLF-terminated, 211-field CSV record per sheet. Field 0 is the bubbled student ID; fields 10 onward are the responses. Everything above the transport layer operates on saved records, so it is unit tested without a scanner attached.
| Phase | ||
|---|---|---|
| 1 | Hardware discovery | ✅ CP210x USB serial, 10c4:ea60 |
| 2 | Communication method | ✅ 38400 8N1, DTR off / RTS on |
| 3 | Protocol discovery | ✅ command sequence + record framing |
| 4 | Direct interface | ✅ working against real hardware |
| 5 | Test scoring | 🔨 scores in the exported report, not yet on screen |
| 6 | Item analysis | ✅ JSON report, shared with omr_final |
| 7 | Native macOS app | ✅ Cocoa window and menu bar, no browser or terminal |
Item analysis is done, sharing its implementation with the omr_final project. Per-student scoring inside the app's own UI is still to come; the JSON report already carries every student's score and missed questions.
An early SwiftUI model layer is parked on the
swiftui-frontend
branch. It predates the protocol discovery and is not part of the build.
src/datalink_scanner/ the application: transport, parser, store, server,
Cocoa shell, CLI and web UI
└── vendor/ verbatim scoring modules from omr_final — do not edit
tests/ offline tests; no scanner needed
scripts/ phase 1–3 discovery tools (see below)
packaging/ app bundle builder, DMG build, Homebrew formula
AppStore/ the store edition: its own entitlements, build script
and dist/ — nothing here touches the Homebrew build
docs/ protocol notes, experiment log, installer analysis
windows_capture_kit/ portable collector for a Windows DataLink Connect PC
captures/ local scan data and library.sqlite3 — gitignored
python3 -m venv .venv && .venv/bin/pip install -e .
.venv/bin/python -m unittest discover -s tests -t tests -vThe discovery tools in scripts/ are what produced docs/PROTOCOL.md:
01_baseline_unplugged.sh/02_connected.sh— run unplugged, then plugged in; the second diffs against the first to identify the device.serial_sniffer.py— read-only capture. Never writes data bytes; it only asserts the DTR/RTS modem lines, without which the scanner sends nothing. Writesraw.bin,hexdump.txt, andmeta.txtper labeled run.controlled_capture.py— one-sheet-at-a-time trial runner that ends a trial on line silence rather than assuming a packet length, so repeats of the same sheet can be compared byte for byte.list_serial_ports.py— port listing, superseded bydatalink-scanner ports.
Releasing a new version: docs/RELEASING.md.
MIT. "Apperson," "DataLink," and "DataLink Connect" are trademarks of their respective owners; this project is independent and unaffiliated, and redistributes no vendor software.