Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
126 commits
Select commit Hold shift + click to select a range
9fa32dc
commit
blaster23 Nov 30, 2025
7d6305f
add info to readme
ed522 Nov 30, 2025
6f2a157
update attendance communicator
ed522 Dec 1, 2025
30747bc
various fixes and improvements
ed522 Dec 4, 2025
4e8ba7e
fix MarkHere messaging
ed522 Dec 4, 2025
8141c72
update gitignore to remove __pycache__
ed522 Dec 4, 2025
8e196c2
fix markhere again
ed522 Dec 4, 2025
5c7f74c
bugfixes
ed522 Dec 9, 2025
95c3dc6
ignore ALL pycaches
ed522 Dec 9, 2025
9ebcd02
fix heartbeat logic
ed522 Dec 9, 2025
20faf8b
various tweaks to get communication working
ed522 Dec 11, 2025
250f78e
create db if it doesn't exist
ed522 Dec 11, 2025
6212e97
add missing 'self.'s and make it so claimed codes can't be reused
ed522 Dec 11, 2025
928c0af
get rid of sqlite-journal
ed522 Dec 11, 2025
ea7f4c0
commit db changes
ed522 Dec 12, 2025
1e64e70
bind to any address
ed522 Dec 12, 2025
e4eb03d
make code communicator error-tolerant (can be disabled if need be) - …
ed522 Dec 16, 2025
d786ad8
reject loopback addresses
ed522 Dec 16, 2025
5cca8a3
print host for diagnostics
ed522 Dec 16, 2025
2c24127
add command-line option to disable attendance features
ed522 Dec 17, 2025
6326b92
add command-line option to disable attendance features
ed522 Dec 18, 2025
fdcb40c
Merge remote-tracking branch 'github/master'
ed522 Dec 18, 2025
a9a058d
remove possibly conflicting server status command
ed522 Dec 18, 2025
7ad4608
improve README.md for more relevant instructions, troubleshooting and…
ed522 Dec 18, 2025
697174d
ignore .venv
ed522 Dec 18, 2025
3d148e2
ignore client_secret.json
ed522 Dec 18, 2025
b725366
change client_secret to secrets (reflects what will be used for auth)
ed522 Dec 18, 2025
c619963
bug: don't include argv[0]
ed522 Dec 18, 2025
9da9e26
add requirements.txt
ed522 Dec 18, 2025
b622660
remove weird italics
ed522 Dec 18, 2025
d4ecbca
remove stale codes from dicts
ed522 Dec 18, 2025
bf14c11
TODO: add windows instructions for virtualenv
ed522 Dec 18, 2025
d654a4a
various bugfixes in stale code removal logic
ed522 Dec 19, 2025
5013318
create SheetsManager: helper to deal with sheets and creds
ed522 Dec 19, 2025
b05296d
fix a heartbeat off-by-one
ed522 Dec 19, 2025
ff14974
integrate sheets into the rest of the bot, replacing sqlite
ed522 Dec 19, 2025
772e262
update README to reflect service account requirements & a new error
ed522 Dec 19, 2025
92d0a39
document import-secrets
ed522 Dec 19, 2025
8264f47
reformat README to include clearer step-by-step instructions on setup…
ed522 Dec 19, 2025
9fca458
ignore gpg-encrypted secrets as well
ed522 Dec 19, 2025
3bd1def
make --disable-attendance work as expected
ed522 Dec 19, 2025
34e0a0a
remove shadowing disable_attendance parameter
ed522 Dec 19, 2025
b0ecfdb
only purge codes >3m past expiry to give the expired message some use
ed522 Dec 19, 2025
f3799f7
fix typo
ed522 Dec 19, 2025
358e861
#1: merge extant changes
ed522 Dec 19, 2025
179df6f
add dependencies for statbotics, pillow
ed522 Dec 19, 2025
51e3f0a
remove fork header
ed522 Dec 19, 2025
14a5d29
add options for relocating .env and secrets.json
ed522 Dec 19, 2025
2aed901
add half-done install script. TODO: systemd, log rotation, uninstall
ed522 Dec 24, 2025
2599260
add helper start.sh script
ed522 Dec 25, 2025
b6e3aa2
fix broken numbering
ed522 Dec 25, 2025
f87cbfd
chmod installed files to have exec permissions
ed522 Dec 25, 2025
7329fcf
minor wording change
ed522 Dec 25, 2025
ef8647f
add a notice that the user can add the dir to PATH
ed522 Dec 25, 2025
cd50323
add etc paths as well (for symlinking)
ed522 Dec 25, 2025
8613ce1
fix .local
ed522 Dec 25, 2025
85aa144
wrap some extra-long lines
ed522 Dec 25, 2025
2f52eac
fix .local v2
ed522 Dec 25, 2025
3134689
add import missing from partial commit
ed522 Dec 25, 2025
c6a6647
add variables for absolute/real/de-usered paths for use in certain f…
ed522 Dec 25, 2025
d818ebb
visually fence pip output
ed522 Dec 25, 2025
9c547d1
extra status/formatting prints
ed522 Dec 25, 2025
94d0169
add symlinking code
ed522 Dec 25, 2025
c41d70e
move strangely-placed prints
ed522 Dec 25, 2025
8dfee53
add systemd unit creation
ed522 Dec 25, 2025
bdbbdcb
make parents of symlink directories if necessary
ed522 Dec 25, 2025
cd23578
add logging options
ed522 Dec 25, 2025
010e74b
add extra notices at the end of the setup process
ed522 Dec 25, 2025
858cbe6
make logging actually do the thing it was supposed to do
ed522 Dec 25, 2025
59e0f9d
add import subcommand
ed522 Dec 25, 2025
40f2637
docs: add (now untested) support for systemd-creds
ed522 Dec 25, 2025
daf0c04
add (now untested) support for systemd-creds
ed522 Dec 25, 2025
b6470e8
add argument parsing/subcommand logic
ed522 Dec 25, 2025
47092d1
TODO: add uninstall command
ed522 Dec 25, 2025
7b741bc
add help subcommand
ed522 Dec 25, 2025
f31be9a
minor string changes
ed522 Dec 25, 2025
d90d02b
fix exit calls
ed522 Dec 25, 2025
354beb6
fix incorrect cp command for user mode
ed522 Dec 25, 2025
90b5c56
touch .env
ed522 Dec 25, 2025
f454bbc
duplicate secrets entry?
ed522 Dec 25, 2025
0c7c391
oops (missing argument)
ed522 Dec 25, 2025
085baad
make choose_option throw on missing options
ed522 Dec 25, 2025
f5a823b
check option validity for installation directory
ed522 Dec 25, 2025
49281c3
make parents if user unit folder does not exist
ed522 Dec 26, 2025
e3b5aeb
correct systemd unit path
ed522 Dec 26, 2025
72dbb63
bug: also catch NoKeyringError
ed522 Dec 26, 2025
78cc953
bug: also catch NoKeyringError
ed522 Dec 26, 2025
bd71729
Merge remote-tracking branch 'origin/master'
ed522 Dec 26, 2025
9c96b03
fix check output call
ed522 Dec 26, 2025
9f482f3
fix wonky service file formatting
ed522 Dec 26, 2025
8d83f94
add sample .env file
ed522 Dec 26, 2025
df3712f
REALLY fix check output call (for systemd-creds encrypt)
ed522 Dec 26, 2025
b4f2cb3
minor doc change (sample.env)
ed522 Dec 26, 2025
6a1d80a
add a check to make sure .env is complete before attempting to launch…
ed522 Dec 26, 2025
23f3a23
less journalctl spam pls
ed522 Dec 26, 2025
5473177
more unit formatting fixes
ed522 Dec 26, 2025
5fc36ae
missing space
ed522 Dec 26, 2025
925ce0f
change confirm's sig: make default_state kw-only and optional
ed522 Dec 26, 2025
45fdf52
add uninstallation instructions (setup command to come)
ed522 Dec 26, 2025
c178903
whitespace
ed522 Dec 26, 2025
c7b6cf2
shebang main.py
ed522 Dec 26, 2025
ec214fe
fix shebang
ed522 Dec 26, 2025
03f34f4
add uninstall function
ed522 Dec 26, 2025
9b4454b
add system-wide uninstall logic
ed522 Dec 26, 2025
1f35f4b
add uninstall to parser and help
ed522 Dec 26, 2025
262fb91
remove '(guided)' from every help option
ed522 Dec 26, 2025
5837fd5
Merge remote-tracking branch 'origin/master'
ed522 Dec 26, 2025
eecfea8
fix errant backtick
ed522 Dec 26, 2025
ba28482
remove old sqlite db references
ed522 Dec 26, 2025
119bf8e
fix .env backup
ed522 Dec 26, 2025
4b28fdb
fix: find correct location
ed522 Dec 26, 2025
c406631
try to remove logs on uninstall
ed522 Dec 26, 2025
6e86e4a
make /opt default
ed522 Dec 26, 2025
0d15649
expand user path in import
ed522 Dec 26, 2025
9a096f3
fix #6: make installer use sudo for ~/.local
ed522 Dec 26, 2025
416955d
defer response (timeouts caused 404s and exceptions)
ed522 Dec 26, 2025
3db5cb3
defer response (timeouts caused 404s and exceptions)
ed522 Dec 26, 2025
521cba6
minor: use local time on the sheet
ed522 Jan 1, 2026
af3b2cd
Also catch JSONDecodeErrors so that a bad message doesn't kill the th…
ed522 Jan 12, 2026
312f4fb
Bump urllib3 from 2.6.2 to 2.6.3
dependabot[bot] Jan 12, 2026
72fbd16
Bump aiohttp from 3.13.2 to 3.13.3
dependabot[bot] Jan 12, 2026
6d80dfd
Merge pull request #8 from ed522/dependabot/pip/urllib3-2.6.3
ed522 Jan 12, 2026
6859303
Merge pull request #9 from ed522/dependabot/pip/aiohttp-3.13.3
ed522 Jan 12, 2026
4bf1f7c
Bump pyasn1 from 0.6.1 to 0.6.2
dependabot[bot] Jan 17, 2026
bea9ac5
Merge pull request #10 from ed522/dependabot/pip/pyasn1-0.6.2
ed522 Jan 17, 2026
96617a4
Merge branch 'master' into master
ed522 Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
.idea/
.vscode/
.env
pycache
**/__pycache__/
.venv/
secrets.json
secrets.json.gpg
196 changes: 196 additions & 0 deletions AttendanceCodeCommunicator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import ipaddress
import logging
import json
import os
import socket
import sys
import threading
from enum import Enum
from json import JSONDecodeError

from dotenv import load_dotenv

from SheetsManager import SheetManager

load_dotenv()

class Status(Enum):
DISCONNECTED = 0
CONNECTING = 1
CONNECTED = 2
class AttendanceCodeCommunicator:

sheet_id = None
sheet_manager = None
received_codes = {}
claimed_codes = []
status: Status = Status.DISCONNECTED
thread: threading.Thread = None

def __init__(self):
"""
:raises ValueError: if no valid sheet exists
"""
secrets_location = os.getenv("secrets_json_path")
if not secrets_location:
secrets_location = "secrets.json"
self.sheet_manager = SheetManager(secrets_location)
self.sheet_id = self.sheet_manager.find_sheet("[attendance-bot]")

if self.sheet_id is None:
raise ValueError('No valid sheet exists to store attendance codes. Either create one '
'with the suffix "[attendance-bot]" (no quotes) or disable '
'attendance features with the command-line switch.')

def _discover(self, sock: socket.socket):

const_version = 1
logging.log(logging.DEBUG, "Discovering available endpoints")

while True:
try:
message = json.loads(sock.recv(1024))
except ValueError:
continue
if message['app'] == 'attendance' and message['type'] == 'discovery' and message['version'] == const_version:

logging.log(logging.DEBUG, "Found an available endpoint")
if ipaddress.ip_address(message['host']).is_loopback:
logging.log(logging.WARNING, "Received valid broadcast, but the host is a loopback address"
f" ({message['host']}). Check the network configuration on the client.")
continue

return message['host']

def _handshake(self, sock: socket.socket):

connect_message = json.loads(sock.recv(1024))
if connect_message['type'] != 'connect':
logging.log(logging.ERROR, f"Failed to connect with endpoint, wrong type {connect_message['type']}")
return False # let endpoint try again

response = {
'type': 'acknowledge',
'targeting': connect_message['type']
}
val1_tmp = json.dumps(response)
val2_tmp = val1_tmp.encode()
sock.send(val2_tmp)
return True

def _communicate_after_handshake(self, sock: socket.socket):

const_code_show_duration = 30 # seconds
const_code_valid_duration = 60 # seconds

counter = 0

while True:

data = sock.recv(1024)
if len(data) == 0:
logging.log(logging.WARN, "Remote endpoint disconnected")
return

message = json.loads(data)

if message['type'] == 'heartbeat':
response = {
'type': 'acknowledge',
'targeting': message['type'],
'counter': counter
}
counter += 1
sock.sendall(bytes(json.dumps(response), 'utf-8'))

elif message['type'] == 'heartbeat_error':
response = {
'type': 'acknowledge',
'targeting': message['type']
}
sock.sendall(bytes(json.dumps(response), 'utf-8'))
logging.log(logging.WARN, f"Logged heartbeat error: expected {counter}, told {message['counter']}")
counter = message['counter'] + 1

elif message['type'] == 'code':
code = int(message['code'])
generation_time = int(message['generation_time'])
response = {
'type': 'acknowledge',
'targeting': message['type'],
'code': code,
'valid_to': generation_time + const_code_show_duration
}
sock.sendall(bytes(json.dumps(response), 'utf-8'))
self.received_codes[response['code']] = generation_time + const_code_valid_duration

# check for expired codes
import datetime
codes_to_remove = []
for code_to_check, expiry in self.received_codes.items():
# check if the code is 3m past expiry - give a chance to see the expired message
if datetime.datetime.now(datetime.UTC).timestamp() > expiry + 180:
codes_to_remove.append(code_to_check)

for code_to_remove in codes_to_remove:
self.received_codes.pop(code_to_remove, None)
if code_to_remove in self.claimed_codes:
self.claimed_codes.remove(code_to_remove)

else:
logging.log(logging.WARN, f"Unknown message {message['type']}, ignoring")

def _communicate(self, should_fail_on_exception: bool):
"""
Attempt to communicate with the remote screen.
Handles both broadcasts and regular communication.
Intended to be run as a thread
:return: None
"""
while True:
try:
logging.log(logging.INFO, f"Communicator thread started")
broadcast_in_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
broadcast_in_sock.bind(('', 5789))
logging.log(logging.INFO, "Now listening on all interfaces, port 5789")
host = self._discover(broadcast_in_sock)
logging.log(logging.INFO, f"Found an endpoint at {host}")
self.status = Status.CONNECTING
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, 5789))

if self._handshake(sock):
self.status = Status.CONNECTED
self._communicate_after_handshake(sock)
except socket.error | JSONDecodeError as e:
if should_fail_on_exception:
logging.log(logging.ERROR, f"Exception raised during communication!")
raise e # terminates thread
else:
logging.log(logging.ERROR, f"Exception raised during communication - {e} - continuing anyways!")
# loop again


def run(self):
# Specs:
#
# A screen sends out a 255.255.255.255 broadcast app: attendance, type: discovery, version: 1 (as of now), host matching local IP
# Server connects to host given in the parameter
#
# On connect, screen sends JSON object with type = "connect"
# Server sends type = "acknowledge", targeting = "connect"
# On every code generation event (screen): type = "code", code = <generated code>, generation_time = <generation Unix timestamp>
# Server acknowledges with type = "acknowledge", targeting = "code", code = <same>, valid_to = <expiry time>
# Every 10s, screen sends type = "heartbeat", counter = <counter that increments each heartbeat>
# Server sends type = "acknowledge", targeting = "heartbeat", counter = <counter>
#
# Errors:
# If the heartbeat counters do not match, server sends type = "heartbeat_error", counter = <corrected value>
# If connection fails, disconnect and try again after 10s
#
# Note that the "server" here is the discord bot, but the server is technically the tablet

logging.basicConfig(level=logging.DEBUG, stream=sys.stdout)
logging.log(logging.INFO, f"Starting attendance communicator")
self.thread = threading.Thread(target=lambda: self._communicate(False), daemon=True)
self.thread.start()
Loading