Skip to content

Commit a8bea41

Browse files
barcode scanner
1 parent 305faa4 commit a8bea41

15 files changed

Lines changed: 515 additions & 68 deletions

.github/workflows/build.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ on:
44
push:
55
branches:
66
- main
7+
- dev
78
workflow_dispatch:
89

910
concurrency:
10-
group: production_environment
11+
group: ${{ github.ref_name }}_environment
1112
cancel-in-progress: true
1213

1314
jobs:
@@ -23,6 +24,17 @@ jobs:
2324
id: lowercase
2425
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
2526

27+
- name: Set image name
28+
id: image
29+
run: |
30+
if [ "${{ github.ref_name }}" = "dev" ]; then
31+
echo "name=check-in-dev" >> $GITHUB_OUTPUT
32+
echo "extra_tag=ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in-dev:dev" >> $GITHUB_OUTPUT
33+
else
34+
echo "name=check-in" >> $GITHUB_OUTPUT
35+
echo "extra_tag=ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in:latest" >> $GITHUB_OUTPUT
36+
fi
37+
2638
- name: Set up Docker Buildx
2739
uses: docker/setup-buildx-action@v3
2840

@@ -39,5 +51,5 @@ jobs:
3951
context: .
4052
push: true
4153
tags: |
42-
ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in:${{ github.sha }}
43-
ghcr.io/${{ steps.lowercase.outputs.owner }}/check-in:latest
54+
ghcr.io/${{ steps.lowercase.outputs.owner }}/${{ steps.image.outputs.name }}:${{ github.sha }}
55+
${{ steps.image.outputs.extra_tag }}

src/api/client.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,43 @@ def get_traffic_light(self):
5656
logging.error(f"Error getting traffic light: {e}")
5757
return "off"
5858

59-
def create_account(self, rfid, *, barcode=None, pid=None):
59+
def lookup_by_pid(self, pid):
60+
"""Returns dict with first_name/last_name/email/pid, or None if not found."""
61+
try:
62+
resp = _req("GET", f"{API_BASE_URL}/accounts/lookup/pid/{pid}", timeout=10)
63+
if resp.status_code == 404:
64+
return None
65+
resp.raise_for_status()
66+
return resp.json()
67+
except Exception as e:
68+
logging.error(f"Error looking up student by pid {pid}: {e}")
69+
return None
70+
71+
def lookup_by_barcode(self, barcode):
72+
"""Returns dict with first_name/last_name/email/pid, or None if not found."""
73+
try:
74+
resp = _req("GET", f"{API_BASE_URL}/accounts/lookup/barcode/{barcode}", timeout=10)
75+
if resp.status_code == 404:
76+
return None
77+
resp.raise_for_status()
78+
return resp.json()
79+
except Exception as e:
80+
logging.error(f"Error looking up student by barcode: {e}")
81+
return None
82+
83+
def create_account(self, rfid, *, barcode=None, pid=None, first_name=None, last_name=None, email=None):
6084
try:
6185
payload = {"rfid": rfid}
6286
if barcode:
6387
payload["barcode"] = barcode
6488
if pid:
6589
payload["pid"] = pid
90+
if first_name:
91+
payload["first_name"] = first_name
92+
if last_name:
93+
payload["last_name"] = last_name
94+
if email:
95+
payload["email"] = email
6696
resp = _req("POST", f"{API_BASE_URL}/accounts", json=payload, timeout=30)
6797
resp.raise_for_status()
6898
return resp.json()

src/controllers/account_controller.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,76 @@ def __init__(self, ctx):
99
def create_account_from_barcode(self, barcode):
1010
self._create(barcode=barcode)
1111

12+
def go_to_review_from_barcode(self, barcode):
13+
canvas = self.ctx.window.canvas
14+
loading = tkinter.Label(
15+
canvas,
16+
text="Looking up student...",
17+
bg="#153246", fg="white", font=("Arial", 25),
18+
)
19+
loading.place(relx=0.5, rely=0.87, anchor="center")
20+
self.ctx.window.update()
21+
22+
student = self.ctx.sheets.lookup_by_barcode(barcode)
23+
loading.destroy()
24+
25+
if student is None:
26+
error = tkinter.Label(
27+
canvas,
28+
text="Student not found. Please enter your details manually.",
29+
bg="#153246", fg="white", font=("Arial", 20),
30+
)
31+
error.place(relx=0.5, rely=0.87, anchor="center")
32+
error.after(3000, error.destroy)
33+
return
34+
35+
self.ctx.nav.go_to_create_account_review(
36+
pid=student["pid"],
37+
first_name=student["first_name"],
38+
last_name=student["last_name"],
39+
email=student["email"],
40+
)
41+
1242
def create_account_from_pid(self, pid):
1343
self._create(pid=pid)
1444

15-
def _create(self, *, barcode=None, pid=None):
45+
def go_to_review_from_pid(self, pid):
46+
canvas = self.ctx.window.canvas
47+
loading = tkinter.Label(
48+
canvas,
49+
text="Looking up student...",
50+
bg="#153246", fg="white", font=("Arial", 25),
51+
)
52+
loading.place(relx=0.5, rely=0.87, anchor="center")
53+
self.ctx.window.update()
54+
55+
student = self.ctx.sheets.lookup_by_pid(pid)
56+
loading.destroy()
57+
58+
if student is None:
59+
error = tkinter.Label(
60+
canvas,
61+
text="Student not found. Please check your PID.",
62+
bg="#153246", fg="white", font=("Arial", 20),
63+
)
64+
error.place(relx=0.5, rely=0.87, anchor="center")
65+
error.after(3000, error.destroy)
66+
return
67+
68+
self.ctx.nav.go_to_create_account_review(
69+
pid=pid,
70+
first_name=student["first_name"],
71+
last_name=student["last_name"],
72+
email=student["email"],
73+
)
74+
75+
def create_account_from_review(self, *, first_name, last_name, email, pid):
76+
if pid:
77+
self._create(pid=pid)
78+
else:
79+
self._create(first_name=first_name, last_name=last_name, email=email)
80+
81+
def _create(self, *, barcode=None, pid=None, first_name=None, last_name=None, email=None):
1682
canvas = self.ctx.window.canvas
1783
inProgress = tkinter.Label(
1884
canvas,
@@ -22,7 +88,14 @@ def _create(self, *, barcode=None, pid=None):
2288
inProgress.place(relx=0.5, rely=0.87, anchor="center")
2389
self.ctx.window.update()
2490

25-
result = self.ctx.sheets.create_account(self.ctx.rfid, barcode=barcode, pid=pid)
91+
result = self.ctx.sheets.create_account(
92+
self.ctx.rfid,
93+
barcode=barcode,
94+
pid=pid,
95+
first_name=first_name,
96+
last_name=last_name,
97+
email=email,
98+
)
2699
inProgress.destroy()
27100

28101
if result is None:
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import logging
2+
import time
3+
from threading import Thread
4+
5+
from screens.check_in_manual import CheckInManual
6+
from screens.create_account_barcode import CreateAccountBarcode
7+
from screens.create_account_manual import CreateAccountManual
8+
9+
10+
class BarcodeScannerController:
11+
def __init__(self, ctx):
12+
self.ctx = ctx
13+
14+
def start(self, scanner):
15+
thread = Thread(target=self._run, args=(scanner,), daemon=True)
16+
thread.start()
17+
18+
def _run(self, scanner):
19+
logging.info("Now reading barcodes")
20+
scanner_error = False
21+
try:
22+
while True:
23+
if scanner_error:
24+
time.sleep(0.5)
25+
if scanner.reconnect():
26+
logging.info("Barcode scanner reconnected")
27+
scanner_error = False
28+
continue
29+
30+
try:
31+
barcode = scanner.read_barcode()
32+
except OSError as e:
33+
logging.error("Barcode scanner disconnected: %s", e)
34+
scanner_error = True
35+
continue
36+
37+
if barcode is None:
38+
continue
39+
40+
logging.debug("Raw barcode received: %r", barcode)
41+
42+
if not scanner.is_valid(barcode):
43+
logging.warning("Invalid barcode rejected: %r", barcode)
44+
continue
45+
46+
logging.info("Barcode scanned: %r", barcode)
47+
curr_frame = self.ctx.nav.get_curr_frame()
48+
49+
if curr_frame == CheckInManual:
50+
self.ctx.window.after(0, lambda b=barcode: self.ctx.check_in.handle_by_pid(b))
51+
elif curr_frame in (CreateAccountBarcode, CreateAccountManual):
52+
self.ctx.window.after(0, lambda b=barcode: self.ctx.account.go_to_review_from_barcode(b))
53+
else:
54+
logging.debug("Barcode scanned on unhandled screen: %s", curr_frame)
55+
except Exception as e:
56+
logging.exception("Barcode scanner thread crashed: %s", e)

src/controllers/navigation_controller.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from screens.transition_screen import TransitionScreen
55
from screens.create_account_barcode import CreateAccountBarcode
66
from screens.create_account_manual import CreateAccountManual
7+
from screens.create_account_no_pid import CreateAccountNoPid
8+
from screens.create_account_review import CreateAccountReview
79
from screens.sign_waiver import SignWaiver
810
from screens.check_in_manual import CheckInManual
911
from screens.qr_codes import QRCodes
@@ -30,6 +32,8 @@ def __init__(self, window, ctx, dev_mode=False):
3032
TransitionScreen,
3133
CreateAccountBarcode,
3234
CreateAccountManual,
35+
CreateAccountNoPid,
36+
CreateAccountReview,
3337
SignWaiver,
3438
CheckInManual,
3539
QRCodes,
@@ -108,6 +112,21 @@ def go_to_create_account_manual(self):
108112
self.get_frame(CreateAccountManual).clear_entries()
109113
self.show_frame(CreateAccountManual)
110114

115+
def go_to_create_account_no_pid(self):
116+
self.get_frame(CreateAccountNoPid).clear_entries()
117+
self.show_frame(CreateAccountNoPid)
118+
119+
def go_to_create_account_review(self, pid="", first_name="", last_name="", email=""):
120+
pid_locked = bool(pid)
121+
self.get_frame(CreateAccountReview).setup(
122+
first_name=first_name,
123+
last_name=last_name,
124+
email=email,
125+
pid=pid,
126+
pid_locked=pid_locked,
127+
)
128+
self.show_frame(CreateAccountReview)
129+
111130
def go_to_create_account(self, on_done):
112131
self.get_frame(TransitionScreen).display(
113132
"Looks like you don't have an account,\nlet's set one up!"

src/controllers/swipe_controller.py

Lines changed: 0 additions & 48 deletions
This file was deleted.

src/hardware/barcode_scanner.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import serial
2+
import logging
3+
from os.path import exists
4+
5+
6+
class BarcodeScanner:
7+
def __init__(self, usb_id):
8+
self._usb_id = usb_id
9+
self._ser = None
10+
self._connect()
11+
12+
def _connect(self):
13+
self._ser = serial.Serial(self._usb_id, baudrate=9600, timeout=0.1)
14+
self._ser.reset_input_buffer()
15+
logging.info("Barcode scanner connected at %s", self._usb_id)
16+
17+
def reconnect(self):
18+
if not exists(self._usb_id):
19+
return False
20+
try:
21+
self._connect()
22+
return True
23+
except Exception:
24+
self._ser = None
25+
return False
26+
27+
def read_barcode(self):
28+
"""Read one barcode from the scanner. Returns stripped string or None."""
29+
# Use read_until(\r) to handle scanners that terminate with CR only
30+
line = self._ser.read_until(b"\r")
31+
if not line:
32+
return None
33+
barcode = line.decode("ascii", errors="ignore").strip()
34+
# Strip Codabar start/stop characters (A, B, C, D) from both ends
35+
barcode = barcode.strip("ABCDabcd")
36+
return barcode if barcode else None
37+
38+
def is_valid(self, barcode):
39+
if not barcode:
40+
return False
41+
if len(barcode) > 32:
42+
return False
43+
return True

0 commit comments

Comments
 (0)