]> Devi Nivas Git - 4180kiosk.git/commitdiff
Initial Commit
authorAdvaith Menon <noreply-git@bp4k.net>
Thu, 29 Jan 2026 15:16:30 +0000 (10:16 -0500)
committerAdvaith Menon <noreply-git@bp4k.net>
Thu, 29 Jan 2026 15:16:30 +0000 (10:16 -0500)
.gitignore [new file with mode: 0644]
default_confs/pictures/ed_qr.png [new file with mode: 0644]
default_confs/pictures/placeholder.png [new file with mode: 0644]
default_confs/site.conf [new file with mode: 0644]
default_confs/style.conf [new file with mode: 0644]
ohdisp/__init__.py [new file with mode: 0644]
ohdisp/config.py [new file with mode: 0644]
ohdisp/model.py [new file with mode: 0644]
ohdisp/views.py [new file with mode: 0644]
run_ohdisp.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..e15106e
--- /dev/null
@@ -0,0 +1,216 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[codz]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#   Usually these files are written by a python script from a template
+#   before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py.cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+# Pipfile.lock
+
+# UV
+#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+# uv.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+# poetry.lock
+# poetry.toml
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
+#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control
+# pdm.lock
+# pdm.toml
+.pdm-python
+.pdm-build/
+
+# pixi
+#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
+# pixi.lock
+#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
+#   in the .venv directory. It is recommended not to include this directory in version control.
+.pixi
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# Redis
+*.rdb
+*.aof
+*.pid
+
+# RabbitMQ
+mnesia/
+rabbitmq/
+rabbitmq-data/
+
+# ActiveMQ
+activemq-data/
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.envrc
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#   JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#   be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#   and can be added to the global gitignore or merged into this file.  For a more nuclear
+#   option (not recommended) you can uncomment the following to ignore the entire idea folder.
+# .idea/
+
+# Abstra
+#   Abstra is an AI-powered process automation framework.
+#   Ignore directories containing user credentials, local state, and settings.
+#   Learn more at https://abstra.io/docs
+.abstra/
+
+# Visual Studio Code
+#   Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 
+#   that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
+#   and can be added to the global gitignore or merged into this file. However, if you prefer, 
+#   you could uncomment the following to ignore the entire vscode folder
+# .vscode/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Marimo
+marimo/_static/
+marimo/_lsp/
+__marimo__/
+
+# Streamlit
+.streamlit/secrets.toml
diff --git a/default_confs/pictures/ed_qr.png b/default_confs/pictures/ed_qr.png
new file mode 100644 (file)
index 0000000..f0fee30
Binary files /dev/null and b/default_confs/pictures/ed_qr.png differ
diff --git a/default_confs/pictures/placeholder.png b/default_confs/pictures/placeholder.png
new file mode 100644 (file)
index 0000000..b0f13d1
Binary files /dev/null and b/default_confs/pictures/placeholder.png differ
diff --git a/default_confs/site.conf b/default_confs/site.conf
new file mode 100644 (file)
index 0000000..e6d104c
--- /dev/null
@@ -0,0 +1,14 @@
+[fratta.ece.gatech.edu]
+; Strings
+window_title = ECE 4180 TA Experience
+site_title = ECE 4180 Office Hours
+queue_title = Queue
+
+nobody_avatar = default_confs/pictures/ed_qr.png
+
+; Actual conf
+api_path_q = https://proxyembedded.bp4k.net/4180q.py
+api_q_element = queue.checkoffQueue,queue.helpQueue
+
+
+api_key = doesnt-matter
diff --git a/default_confs/style.conf b/default_confs/style.conf
new file mode 100644 (file)
index 0000000..5eee955
--- /dev/null
@@ -0,0 +1,39 @@
+[fratta.ece.gatech.edu]
+title__family = Lato
+title__size = 60
+title__weight = bold
+title__slant = roman
+title__bgcolor = #000000
+title__fgcolor = #ffff00
+
+queue__family = Lato
+queue__size = 30
+queue__weight = bold
+queue__slant = roman
+queue__bgcolor = #000000
+queue__fgcolor = #ffff00
+
+picframe__bgcolor = #000000
+
+qframe__bgcolor = #000000
+
+q_checkoff__family = Lato
+q_checkoff__size = 15
+q_checkoff__weight = normal
+q_checkoff__slant = italic
+q_checkoff__bgcolor = #006600
+q_checkoff__fgcolor = #ffffff
+
+q_help__family = Lato
+q_help__size = 15
+q_help__weight = normal
+q_help__slant = italic
+q_help__bgcolor = #ff3300
+q_help__fgcolor = #ffffff
+
+q_empty__family = Helvetica
+q_empty__size = 15
+q_empty__weight = normal
+q_empty__slant = italic
+q_empty__bgcolor = #000000
+q_empty__fgcolor = #ffffff
diff --git a/ohdisp/__init__.py b/ohdisp/__init__.py
new file mode 100644 (file)
index 0000000..d3177cf
--- /dev/null
@@ -0,0 +1,33 @@
+import argparse
+import sys
+
+from .views import Root
+from .config import ConfigContainer
+from .config import SITE_CONF_PATH, STYLE_CONF_PATH
+
+
+def main(argv):
+    parser = argparse.ArgumentParser(
+                prog="ohdisp",
+                description="Displays TA Office Hours");
+    parser.add_argument('-c', '--config', default=SITE_CONF_PATH);
+    parser.add_argument('-d', '--domain', default="DEFAULT");
+    parser.add_argument('-s', '--stylesheet', default=STYLE_CONF_PATH);
+    parser.add_argument('-f', '--fullscreen', action="store_true");
+
+    args = parser.parse_args(argv[1:])
+
+    config = ConfigContainer(site=args.config, style=args.stylesheet);
+    config.site._set_site(args.domain);
+    config.style._set_site(args.domain);
+
+    root_tk = Root(config);
+
+    if args.fullscreen:
+        root_tk.fullscreen();
+
+    root_tk.mainloop();
+
+
+if __name__ == "__main__":
+    main(sys.argv)
diff --git a/ohdisp/config.py b/ohdisp/config.py
new file mode 100644 (file)
index 0000000..bee6bde
--- /dev/null
@@ -0,0 +1,49 @@
+import os
+import configparser
+
+
+__all__ = ['Config']
+
+
+DEFAULT_SITE = "DEFAULT";
+SITE_CONF_PATH = "~/.ohdisp/site.conf";
+STYLE_CONF_PATH = "~/.ohdisp/style.conf";
+
+
+class Config(object):
+    def __init__(self, conf_path):
+        self._conf_path = conf_path;
+        self._config = configparser.ConfigParser(comment_prefixes=(';',));
+        self._config.read(os.path.expanduser(self._conf_path));
+
+        self._set_site(DEFAULT_SITE);
+
+    def _set_site(self, site):
+        if site not in self._config:
+            raise KeyError("'{}' does not exist in config".format(site));
+        self._site = site;
+
+    def _save(self):
+        with open(os.path.expanduser(self._conf_path), 'w') as fp:
+            self._config.write(fp);
+
+    def __getattr__(self, attr):
+        if attr.startswith("_"):
+            return super().__getattr__(attr);
+        return self._config[self._site][attr];
+
+    def __setattr__(self, attr, value):
+        if attr.startswith("_"):
+            super().__setattr__(attr, value);
+            return
+        self._config[self._site][attr] = value;
+
+
+class ConfigContainer(object):
+    def __init__(self, *_, **config_paths):
+        # map from config name to path
+        self._confs = {x: Config(config_paths[x]) for x in config_paths};
+
+    def __getattr__(self, attr):
+        return self._confs[attr];
+
diff --git a/ohdisp/model.py b/ohdisp/model.py
new file mode 100644 (file)
index 0000000..e551ef9
--- /dev/null
@@ -0,0 +1,159 @@
+import time
+import datetime
+
+import tkinter as tk
+from tkinter.font import Font
+
+import urllib3
+import pyttsx3
+
+class QueueModel(object):
+    def __init__(self, config, *args, **kwargs):
+        self._cfg = config;
+        self._funcs = tuple(self.parse_cfg(self._cfg.site.api_q_element));
+
+    def fetch(self):
+        resp = urllib3.request(
+                "GET",
+                self._cfg.site.api_path_q)
+        json = resp.json();
+        result = [];
+        for func in self._funcs:
+            result += func(json);
+
+        return result;
+
+    def thread(self, tk_parent):
+        n_students = 0
+        cur_students = [];
+        self.build_elements(cur_students, tk_parent);
+        while True:
+            students = self.fetch();
+            nu_students = len(students);
+            if (nu_students != n_students):
+                d = self.diff_students(cur_students, students);
+                self.build_elements(students, tk_parent);
+                self.announce(d);
+                n_students = nu_students;
+                cur_students = students;
+            time.sleep(2);
+
+    def announce(self, data):
+        new_joinees, d = data
+        if new_joinees:
+            sts = "Student{} {} {} joined the queue!".format(
+                    "" if len(d) < 2 else "s",
+                    ", ".join(map(lambda x: x["studentName"], d)),
+                    "has" if len(d) < 2 else "have",
+                    );
+            pyttsx3.speak(sts)
+
+    def build_elements(self, students, tk_parent):
+        for el in tk_parent.winfo_children():
+            el.destroy()
+
+        checkoff_fnt = Font(tk_parent,
+                            family=self._cfg.style.q_checkoff__family,
+                            size=int(self._cfg.style.q_checkoff__size),
+                            weight=self._cfg.style.q_checkoff__weight,
+                            slant=self._cfg.style.q_checkoff__slant,
+                            );
+
+        help_fnt = Font(tk_parent,
+                        family=self._cfg.style.q_help__family,
+                        size=int(self._cfg.style.q_help__size),
+                        weight=self._cfg.style.q_help__weight,
+                        slant=self._cfg.style.q_help__slant,
+                        );
+
+        null_fnt = Font(tk_parent,
+                        family=self._cfg.style.q_empty__family,
+                        size=int(self._cfg.style.q_empty__size),
+                        weight=self._cfg.style.q_empty__weight,
+                        slant=self._cfg.style.q_empty__slant,
+                        );
+
+        if len(students) == 0:
+            lbl = tk.Label(tk_parent,
+                           text="The queue is empty\nFeel free to join!",
+                           font=checkoff_fnt,
+                           fg=self._cfg.style.q_empty__fgcolor,
+                           bg=self._cfg.style.q_empty__bgcolor,
+                           padx=4,
+                           pady=4,
+                           anchor='n'
+                           );
+            lbl.pack(side='top', fill='x', anchor='n', expand=1);
+            return
+
+        for stu in students:
+            if stu["queueType"] == "checkoff":
+                lbl = tk.Label(tk_parent,
+                               text="{} ({} {})".format(
+                                   stu["studentName"], stu["labId"],
+                                   stu["partId"]),
+                               font=checkoff_fnt,
+                               fg=self._cfg.style.q_checkoff__fgcolor,
+                               bg=self._cfg.style.q_checkoff__bgcolor,
+                               padx=4,
+                               pady=4,
+                               anchor='w'
+                               );
+            else:
+                lbl = tk.Label(tk_parent,
+                               text=stu["studentName"],
+                               font=checkoff_fnt,
+                               fg=self._cfg.style.q_help__fgcolor,
+                               bg=self._cfg.style.q_help__bgcolor,
+                               padx=4,
+                               pady=4,
+                               anchor='w'
+                               );
+
+            lbl.pack(side='top', fill='x', anchor='n', expand=0);
+
+    @classmethod
+    def diff_students(cls, cur_students, students):
+        cur_students = sorted(cur_students,
+                              key=lambda x: datetime.datetime.fromisoformat(
+                                  x["joinedAt"]));
+        students = sorted(students,
+                          key=lambda x: datetime.datetime.fromisoformat(
+                              x["joinedAt"]));
+
+        i = 0
+        j = 0
+        l = list()
+
+        while i < len(cur_students) and j < len(students):
+            if cur_students[i]["entryId"] == students[j]["entryId"]:
+                i += 1
+                j += 1
+            else:
+                if len(cur_students) > len(students):
+                    l.append(cur_students[i]);
+                    i += 1
+                elif len(students) > len(cur_students):
+                    l.append(students[j]);
+                    j += 1
+
+        if i < len(cur_students):
+            l += cur_students[i:]
+        elif j < len(students):
+            l += students[j:]
+
+        return (
+                len(students) > len(cur_students),
+                l
+               )
+
+    def parse_cfg(self, line):
+        for arg in line.split(","):
+            data = arg.split(".");
+            def func(dic, x=data):
+                curr = dic;
+                for i in x:
+                    curr = curr[i];
+                return curr;
+            yield func;
+
diff --git a/ohdisp/views.py b/ohdisp/views.py
new file mode 100644 (file)
index 0000000..490fb06
--- /dev/null
@@ -0,0 +1,105 @@
+import os
+import threading
+
+import tkinter as tk
+from tkinter.font import Font
+from .model import QueueModel
+
+from PIL import ImageTk, Image
+
+
+class TADisplayFrame(tk.Frame):
+    def __init__(self, parent, config, *args, **kwargs):
+        super().__init__(parent, *args, **kwargs);
+
+        self._cnf = config;
+
+        title_font = Font(self,
+                          family=self._cnf.style.title__family,
+                          size=int(self._cnf.style.title__size),
+                          weight=self._cnf.style.title__weight,
+                          slant=self._cnf.style.title__slant,
+                          );
+
+        self._title = tk.Label(self, text=self._cnf.site.site_title,
+                               font=title_font,
+                               bg=self._cnf.style.title__bgcolor,
+                               fg=self._cnf.style.title__fgcolor);
+        self._title.pack(side='top', anchor='n', fill='x');
+
+        self._frame = StudentCheckoffFrame(self, self._cnf);
+        self._frame.pack(side='right', fill="both");
+        self._frame = TAPictureFrame(self, self._cnf);
+        self._frame.pack(side='top', fill='both', expand=1);
+
+
+class TAPictureFrame(tk.Frame):
+    def __init__(self, parent, config, *args, **kwargs):
+        super().__init__(parent, *args, **kwargs);
+
+        self._cnf = config;
+        self['bg'] = self._cnf.style.picframe__bgcolor;
+
+        im = Image.open(
+                os.path.expanduser(
+                    self._cnf.site.nobody_avatar));
+        im.thumbnail((512, 512), Image.Resampling.LANCZOS);
+        self._img = ImageTk.PhotoImage(im);
+        lbl = tk.Label(self, image=self._img);
+        lbl.pack(side="top");
+        lbl2 = tk.Label(self, text=("This feature is a work in progress!"
+                                    "\nAsk questions on Ed Discussion"
+                                    " post office hours"
+                                    "\n\nDoing a video checkoff? Make sure "
+                                    "to show your code, face and buzzcard!"),
+                        font=("Lato", 20, "normal"),
+                        bg="#000000",
+                        fg="#FFFFFF");
+        lbl2.pack(side="top");
+
+
+
+class StudentCheckoffFrame(tk.Frame):
+    def __init__(self, parent, config, *args, **kwargs):
+        super().__init__(parent, *args, **kwargs);
+
+        self._cnf = config;
+
+        self._model = QueueModel(self._cnf);
+
+        title_font = Font(self,
+                          family=self._cnf.style.queue__family,
+                          size=int(self._cnf.style.queue__size),
+                          weight=self._cnf.style.queue__weight,
+                          slant=self._cnf.style.queue__slant,
+                          );
+
+        self._title = tk.Label(self, text=self._cnf.site.queue_title,
+                               font=title_font,
+                               bg=self._cnf.style.queue__bgcolor,
+                               fg=self._cnf.style.queue__fgcolor);
+        self._title.pack(side='top', anchor='n', fill='x');
+
+        self._actualframe = tk.Frame(self,
+                                     bg=self._cnf.style.qframe__bgcolor);
+        self._actualframe.pack(side='top', fill='both', expand=1);
+
+        t = threading.Thread(target=self._model.thread,
+                             args=(self._actualframe,),
+                             daemon=True)
+        t.start()
+
+
+class Root(tk.Tk):
+    def __init__(self, config, *args, **kwargs):
+        super().__init__(*args, **kwargs);
+
+        self._cnf = config;
+
+        self.title(self._cnf.site.window_title);
+
+        self._tadf = TADisplayFrame(self, self._cnf);
+        self._tadf.pack(fill='both', expand=1);
+
+    def fullscreen(self):
+        self.attributes("-fullscreen", True);
diff --git a/run_ohdisp.py b/run_ohdisp.py
new file mode 100755 (executable)
index 0000000..f4de576
--- /dev/null
@@ -0,0 +1,9 @@
+#!/usr/bin/python3
+
+import sys
+
+from ohdisp import main
+
+
+if __name__ == "__main__":
+    main(sys.argv)