Hackster is hosting Hackster Holidays, Ep. 6: Livestream & Giveaway Drawing. Watch previous episodes or stream live on Monday!Stream Hackster Holidays, Ep. 6 on Monday!
James OoiBenji OoiOscar OoiMatthew McGowan
Published

RISE: Recorder of Individual Student Engagement

RISE: A revolutionary AI device turning color-coded hand raises into real-time student participation data -- fair and efficient.

IntermediateFull instructions provided3 hours162

Things used in this project

Hardware components

FireBeetle ESP32 IOT Microcontroller (Supports Wi-Fi & Bluetooth)
DFRobot FireBeetle ESP32 IOT Microcontroller (Supports Wi-Fi & Bluetooth)
×1
DFRobot Pixy 2 CMUcam5 Image Sensor
×1
USB-A to Micro-USB Cable
USB-A to Micro-USB Cable
×1
USB-A to C cable
×1
Breadboard (generic)
Breadboard (generic)
×1

Software apps and online services

Arduino IDE
Arduino IDE
python

Story

Read more

Schematics

FireBeetle 2 ESP32-E connections to Pixy2

How to connect FireBeetle 2 ESP32-E to the Pixy2 to allow I2C serial communication

Code

PixyI2C.h

C/C++
From https://pixycam.com/downloads-pixy2/ ; include with your Sketch, because Arduino Cloud WebEditor gives error on loading Library via .zip file
//
// begin license header
//
// This file is part of Pixy CMUcam5 or "Pixy" for short
//
// All Pixy source code is provided under the terms of the
// GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html).
// Those wishing to use Pixy source code, software and/or
// technologies under different licensing terms should contact us at
// cmucam@cs.cmu.edu. Such licensing terms are available for
// all portions of the Pixy codebase presented here.
//
// end license header
//
// This file is for defining the link class for I2C communications.  
//
// Note, the PixyI2C class takes an optional argument, which is the I2C address 
// of the Pixy you want to talk to.  The default address is 0x54 (used when no 
// argument is used.)  So, for example, if you wished to talk to Pixy at I2C 
// address 0x55, declare like this:
//
// PixyI2C pixy(0x55);
//

#ifndef _PIXYI2C_H
#define _PIXYI2C_H

#include "TPixy.h"
#include "Wire.h"

#define PIXY_I2C_DEFAULT_ADDR           0x54  

class LinkI2C
{
public:
  void init()
  {
    Wire.begin();
  }
  void setArg(uint16_t arg)
  {
    if (arg==PIXY_DEFAULT_ARGVAL)
      addr = PIXY_I2C_DEFAULT_ADDR;
	else
	  addr = arg;
  }
  uint16_t getWord()
  {
    uint16_t w;
	uint8_t c;
	Wire.requestFrom((int)addr, 2);
    c = Wire.read();
    w = Wire.read();
    w <<= 8;
    w |= c; 
    return w;
  }
  uint8_t getByte()
  {
	Wire.requestFrom((int)addr, 1);
	return Wire.read();
  }

  int8_t send(uint8_t *data, uint8_t len)
  {
    Wire.beginTransmission(addr);
    Wire.write(data, len);
	Wire.endTransmission();
	return len;
  }
	
private:
  uint8_t addr;
};

typedef TPixy<LinkI2C> PixyI2C;

#endif

Python webserver: server.py

Python
Python-based webserver for interacting with RISE system and receiving data from RISE hardware; requires a .PNG image file called riselogo.png to be placed in directory serverdir/static, where server.py resides in serverdir; this can be found on Github repo.
from flask import Flask, request, render_template, redirect, url_for
import sqlite3
import sys
from io import StringIO
try:
    from RISE_server import participationater as pp
except:
    import participationater as pp
import argparse
import threading
import datetime

listening_thread = None

def _parse_args():
    parser = argparse.ArgumentParser(description="RISE with multiple modes of operation")
    parser.add_argument("--dbname", help="Name of the database", default="test0.db", required=False)
    parser.add_argument("--device_ip", help="IP addr of RISE device", default="192.168.10.42", required=False)
    parser.add_argument("--device_port", help="PORT of RISE device", default="80", required=False)

    args = parser.parse_args()
    return args

app = Flask(__name__)

# Connect to the SQLite database
#conn = sqlite3.connect('test0.db', check_same_thread=False)  # We add check_same_thread=False because Flask runs on multiple threads

# Redirect stdout to a string buffer
sys.stdout = mystdout = StringIO()

@app.route('/', methods=['GET', 'POST'])
def home():
    global dbname, c, listening_thread, ipaddr, port
    class_name=""
    if request.method == 'POST':
        if 'add_student' in request.form:
            assert pp.conn is not None
            student_name = request.form.get('student_name')
            class_name = request.form.get('class_name')
            signature = request.form.get('signature')
            pp.add_student(c, student_name,class_name,signature)
            print(f"Added student: {student_name} in class: {class_name} with signature: {signature}")
        elif 'delete_student' in request.form:
            assert pp.conn is not None
            student_name = request.form.get('student_name')
            class_name = request.form.get('class_name')
            pp.delete_student(c, student_name,class_name)
            print(f"Deleted student: {student_name} from class: {class_name}")
        elif 'show_class' in request.form:
            assert pp.conn is not None
            class_name = request.form.get('class_name')
            pp.classlist(c,class_name=class_name)
        elif 'show_student' in request.form:
            assert pp.conn is not None
            student_name = request.form.get('student_name')
            class_name = request.form.get('class_name')
            c.execute("SELECT * FROM class_student WHERE student_name = ?", (student_name,))
            info = c.fetchall()
            print(f"Student information for {student_name}: {info}")
            print(f"Student information for {student_name}: {info}")
        elif 'show_raises' in request.form:
            assert pp.conn is not None
            class_name = request.form.get('class_name')
            pp.raiseslist(c,class_name=class_name)
        elif 'startmonitor' in request.form:
            class_name = request.form.get('class_name')
            pp.stop_listen = False
            def bgtask():
                pp.listen_to_events(f"http://{ipaddr}:{port}/events", class_name)
            listening_thread = threading.Thread(target=bgtask)
            listening_thread.start()
            print(f"Listening to http://{ipaddr}:{port}/events for events in class {class_name}")
        elif 'stopmonitor' in request.form:
            class_name = request.form.get('class_name')
            print(f"Stop listening to http://{ipaddr}:{port}/events for events in class {class_name}")
            pp.stop_listen=True
            if listening_thread is not None:
                listening_thread.join()
                listening_thread = None
        elif 'refresh' in request.form:
            class_name = request.form.get('class_name')
            pass

    # Get the stdout string
    stdout_string = mystdout.getvalue()
    return render_template('index.html', stdout_string=stdout_string, dbname=dbname, class_name=class_name, hardwareip=f"{ipaddr}:{port}")

@app.route('/class/<class_name>', methods=['GET'])
def class_handraises(class_name):
    global c
    date=datetime.datetime.now()
    datestr = f'{date:%Y-%m-%d}'
    c.execute('''
        SELECT class_student.student_name, COUNT(handRaises.signature)
        FROM class_student
        LEFT JOIN handRaises ON class_student.signature = handRaises.signature
        AND DATE(handRaises.raise_time) = DATE(?) and class_student.class_name=handRaises.class_name
        WHERE class_student.class_name = ? 
        GROUP BY class_student.student_name
        ORDER BY COUNT(handRaises.signature) ASC
    ''', (date,class_name))
    students = c.fetchall()
    return render_template('classtable.html', students=students, class_name=class_name, datestr=datestr, listening_status=f"Listening is {'on' if not pp.stop_listen else 'off'}")

if __name__ == '__main__':
    import os
    args = _parse_args()
    c, pp.conn = pp.dbcc(args.dbname)
    dbname = args.dbname
    ipaddr = args.device_ip
    port = args.device_port
    pp.stop_listen=True
    app.run(debug=True)

HTML for Python webserver: index.html

HTML
Home page for RISE Management webpage
<!DOCTYPE html>
<html>
<head>
    <title>RISE Management</title>
    <style>
        .header {
            display: flex;
            align-items: center;  /* This will vertically align the items in the middle */
            justify-content: center; /* This will horizontally align the items in the middle */
        }
        .logo {
            width: 100px;  /* Adjust these values to get the size you want */
            height: 100px;
            margin-right: 10px; /* Adds some spacing between the logo and the title */
        }
        body {
            font-family: Arial, sans-serif;
            background-color: #f0f8ff;
            margin: 10px;
            padding: 10px;
        }

        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }

        h1 {
            text-align: center;
            color: #333;
        }

        form {
            background-color: #fff;
            border: 1px solid #ddd;
            padding: 20px;
            border-radius: 5px;
            box-shadow: 0px 0px 10px rgba(0,0,0,0.1);
        }

        form input[type="text"],
        form input[type="number"] {
            width: 80%;
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }

        form input[type="submit"] {
            background-color: #0099cc;
            color: #fff;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
        }

        form input[type="submit"]:hover {
            background-color: #007399;
        }

        .output {
            margin-top: 20px;
            background-color: #eee;
            padding: 20px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }

        .output textarea {
            width: 97%;
            height: 400px;
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }
    </style>
</head>
<body>
    <div class="header">
        <img class="logo" src="{{ url_for('static', filename='riselogo.png') }}" alt="Logo">&nbsp
        <h1>Classroom Manager</h1>
    </div>
    <p>Database connected to: {{ dbname }}
     <p>Hardware IP Address: {{ hardwareip }}</p> <button id="go-button">Class Monitor Tab</button><button id="device-button">Device Tab</button><br><br>
    <form method="POST">
        <input type="text" name="class_name" placeholder="Class Name" value="{{ class_name }}">
        <input type="text" name="student_name" placeholder="Student Name">
        <input type="text" name="signature" placeholder="Color Signature"><br><br>
        <button type="submit" name="add_student">Add Student</button>
        <button type="submit" name="delete_student">Delete Student</button>
        <button type="submit" name="show_class">Show Class List</button>
        <button type="submit" name="show_student">Show Student</button>
        <button type="submit" name="show_raises">Show Handraises</button><Br><br>
        <button type="submit" name="startmonitor">StartMonitor</button>
        <button type="submit" name="stopmonitor">StopMonitor</button>
        <button type="submit" name="refresh">Refresh</button><br><br>
    </form>
    <div class="output">
        <textarea rows="100" readonly>{{ stdout_string }}</textarea>
    </div>
    <script>
        document.getElementById('go-button').onclick = function() {
            var className = document.getElementsByName('class_name')[0].value;
            if (className === '') {
                alert('Please enter a class name');
            } else {
                var win = window.open('/class/' + className, className);
                win.focus();
            }
        };
        document.getElementById('device-button').onclick = function() {
            var ip_address = '{{ hardwareip }}';  // Fetch the IP address from Jinja2
            var win = window.open('http://' + ip_address, 'risedevice');
            win.focus()
        };
    </script>
</body>
</html>

Main Arduino Code: RISE_hardware.ino

C/C++
Upload this to the FireBeetle ESP32 via Arduino Cloud Web Editor
//#include <PixySPI_SS.h>
#include "PixyI2C.h"
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <Preferences.h>

String webhtml = "<html><body><h1>Hello, this is your ESP32!</h1><div id=\"log\"></div><script>var source = new EventSource('/events');"
"source.onmessage = function(event) {document.getElementById('log').innerHTML += event.data + '<br>';};</script></body></html>";

// int SSPin= D13;
//int ledPin = D9;
const char* ssid = "Ooi Circle";
const char* password = "Welcome, NSA!";

const char* prefdict = "ParticiPatron";
double secondscounter = 0.0;
Preferences prefs;
int msdelay = 1000;
int msdaddr = 0;
IPAddress localIP(192, 168, 10, 42);  // Desired static IP address
IPAddress gateway(192, 168, 10, 1);  // IP address of the network gateway
IPAddress subnet(255, 255, 255, 0); 

//PixySPI_SS pixy(SSPin);
PixyI2C pixy;
AsyncWebServer server(80);
AsyncEventSource events("/events");
// struct Student {
//   int signature;
//   int angle_lower;
//   int angle_upper;
//   int times_seen;
// };

// struct Student allstudents[2];
  
void message_out(String msg) {
  Serial.println(msg);
  events.send(msg.c_str());
}

void setup() {
  // put your setup code here, to run once:
  char buf[50];
//  pinMode(ledPin, OUTPUT);

  Serial.begin(115200);

  prefs.begin(prefdict,false);
  msdelay = prefs.getInt("msdelay", msdelay);
  prefs.end();

  sprintf(buf, "msdelay initialized to %d from preferences",msdelay);
  Serial.println(buf);
  Serial.println("Attempting Wifi connetion...");
  if (1==1) {
    WiFi.config(localIP, gateway, subnet);
    WiFi.begin(ssid, password);
    while (WiFi.status() != WL_CONNECTED) {
      delay(1000);
      Serial.println("Connecting to WiFi...");
    }
    Serial.println("Connected.");
    Serial.println(WiFi.localIP());

    server.addHandler(&events);
    server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
      String html = "<!DOCTYPE html><html><style>body {font-family: Arial, sans-serif;}</style><body><h1>RISE Device Portal</h1><div id='log'></div><script>var source = new EventSource('/events');source.onmessage = function(event) {var timestamp = new Date().toLocaleString();document.getElementById('log').innerHTML += timestamp + ': ' + event.data + '<br>';}</script></body></html>";
      request->send(200, "text/html", html);
    });
    server.on("/control", HTTP_GET, [](AsyncWebServerRequest *request){
      String html = "<html><style>body {font-family: Arial, sans-serif;}</style><body><h1>Delay in ms: " + String(msdelay) + "</h1>"
                  "<form method='POST' action='/update'>"
                  "New Value: <input type='number' name='newvalue'>"
                  "<input type='submit' value='Update'>"
                  "</form></body></html>";
      request->send(200, "text/html", html);
    });

    server.on("/update", HTTP_POST, [](AsyncWebServerRequest *request){
      if (request->hasArg("newvalue")) {
        int newValue = request->arg("newvalue").toInt();
        msdelay = newValue;
        prefs.begin(prefdict,false);
        prefs.putInt("msdelay",msdelay);
        prefs.end();
        Serial.println("Variable updated and saved: " + String(msdelay));
      }
      else {
        Serial.println("Variable not updated: " + String(msdelay));
      }
      request->redirect("/");
    });
    server.begin();
    
  }

  pixy.init();
  // allstudents[0].signature=12;
  // allstudents[0].angle_lower=-45;
  // allstudents[0].angle_upper=45;
  // allstudents[0].times_seen=0;

  // allstudents[1].signature=13;
  // allstudents[1].angle_lower=-45;
  // allstudents[1].angle_upper=45;
  // allstudents[1].times_seen=0;
  

}

void loop2(){

  message_out("Testing");
  delay(1000);
}

void loop() {
  // put your main code here, to run repeatedly:
  static int i = 0;
  int j;
  uint16_t numblocks;
  char buf[50]; 
  
  // grab blocks!
  numblocks = pixy.getBlocks();
  secondscounter = secondscounter + msdelay/1000.0;
  
  // If there are detect blocks, print them!
  if (numblocks)
  {
    Serial.println("some blocks");
    for (j=0;j<numblocks;j++) {
      //pixy.blocks[j].print();
      forwardblock(pixy.blocks[j], secondscounter);

    }
  } 
  else {
    sprintf(buf,"signature:-1,angle:0,seconds:%f",secondscounter);
    message_out(buf);
  }
  delay(msdelay);
}

int decimal_to_octal(int decimal) {
  int quotient;
  int octal=0;
  int i=1;

  quotient = decimal;

  while (quotient != 0) {
        octal += (quotient % 8) * i;
        quotient /= 8;
        i *= 10;
  }
  return octal;
}

void forwardblock(Block blk, double secondscounter) {
  int j;
  int16_t realangle, realsig;
  char buf[50];
  String output="";
  realangle = int16_t(blk.angle);
  realsig = decimal_to_octal(blk.signature);
  sprintf(buf, "signature:%d,angle:%d,seconds:%f\n", realsig, realangle,secondscounter);
  output = output+(String)buf;
  // for (j=0;j<numstudents;j++) {
  //   if (studentinfo[j].signature==realsig) {
  //     if ((studentinfo[j].angle_lower<realangle) && (studentinfo[j].angle_upper>realangle)) {
  //       studentinfo[j].times_seen++;
  //       sprintf(buf, "Have seen student %d %d times\n", j, studentinfo[j].times_seen);
  //       output = output+(String)buf;
  //       break;
  //     }
  //   }
  // }
  message_out(output);
}

TPixy.h

C/C++
From https://pixycam.com/downloads-pixy2/; include with your Sketch, because Arduino Cloud WebEditor gives error on loading Library via .zip file
//
// begin license header
//
// This file is part of Pixy CMUcam5 or "Pixy" for short
//
// All Pixy source code is provided under the terms of the
// GNU General Public License v2 (http://www.gnu.org/licenses/gpl-2.0.html).
// Those wishing to use Pixy source code, software and/or
// technologies under different licensing terms should contact us at
// cmucam@cs.cmu.edu. Such licensing terms are available for
// all portions of the Pixy codebase presented here.
//
// end license header
//
// This file is for defining the Block struct and the Pixy template class.
// (TPixy).  TPixy takes a communication link as a template parameter so that 
// all communication modes (SPI, I2C and UART) can share the same code.  
//

#ifndef _TPIXY_H
#define _TPIXY_H

#include "Arduino.h"

// Communication/misc parameters
#define PIXY_INITIAL_ARRAYSIZE      30
#define PIXY_MAXIMUM_ARRAYSIZE      130
#define PIXY_START_WORD             0xaa55
#define PIXY_START_WORD_CC          0xaa56
#define PIXY_START_WORDX            0x55aa
#define PIXY_MAX_SIGNATURE          7
#define PIXY_DEFAULT_ARGVAL         0xffff

// Pixy x-y position values
#define PIXY_MIN_X                  0L
#define PIXY_MAX_X                  319L
#define PIXY_MIN_Y                  0L
#define PIXY_MAX_Y                  199L

// RC-servo values
#define PIXY_RCS_MIN_POS            0L
#define PIXY_RCS_MAX_POS            1000L
#define PIXY_RCS_CENTER_POS         ((PIXY_RCS_MAX_POS-PIXY_RCS_MIN_POS)/2)

 
enum BlockType
{
	NORMAL_BLOCK,
	CC_BLOCK
};

struct Block 
{
  // print block structure!
  void print()
  {
    int i, j;
    char buf[128], sig[6], d;
	bool flag;	
    if (signature>PIXY_MAX_SIGNATURE) // color code! (CC)
	{
      // convert signature number to an octal string
      for (i=12, j=0, flag=false; i>=0; i-=3)
      {
        d = (signature>>i)&0x07;
        if (d>0 && !flag)
          flag = true;
        if (flag)
          sig[j++] = d + '0';
      }
      sig[j] = '\0';	
      sprintf(buf, "CC block! sig: %s (%d decimal) x: %d y: %d width: %d height: %d angle %d\n", sig, signature, x, y, width, height, angle);
    }			
	else // regular block.  Note, angle is always zero, so no need to print
      sprintf(buf, "sig: %d x: %d y: %d width: %d height: %d\n", signature, x, y, width, height);		
    Serial.print(buf); 
  }
  uint16_t signature;
  uint16_t x;
  uint16_t y;
  uint16_t width;
  uint16_t height;
  uint16_t angle;
};



template <class LinkType> class TPixy
{
public:
  TPixy(uint16_t arg=PIXY_DEFAULT_ARGVAL);
  ~TPixy();
	
  uint16_t getBlocks(uint16_t maxBlocks=1000);
  int8_t setServos(uint16_t s0, uint16_t s1);
  int8_t setBrightness(uint8_t brightness);
  int8_t setLED(uint8_t r, uint8_t g, uint8_t b);
  void init();
  
  Block *blocks;
	
private:
  boolean getStart();
  void resize();

  LinkType link;
  boolean  skipStart;
  BlockType blockType;
  uint16_t blockCount;
  uint16_t blockArraySize;
};


template <class LinkType> TPixy<LinkType>::TPixy(uint16_t arg)
{
  skipStart = false;
  blockCount = 0;
  blockArraySize = PIXY_INITIAL_ARRAYSIZE;
  blocks = (Block *)malloc(sizeof(Block)*blockArraySize);
  link.setArg(arg);
}

template <class LinkType> void TPixy<LinkType>::init()
{
  link.init();
}

template <class LinkType> TPixy<LinkType>::~TPixy()
{
  free(blocks);
}

template <class LinkType> boolean TPixy<LinkType>::getStart()
{
  uint16_t w, lastw;
 
  lastw = 0xffff;
  
  while(true)
  {
    w = link.getWord();
    if (w==0 && lastw==0)
	{
      delayMicroseconds(10);
	  return false;
	}		
    else if (w==PIXY_START_WORD && lastw==PIXY_START_WORD)
	{
      blockType = NORMAL_BLOCK;
      return true;
	}
    else if (w==PIXY_START_WORD_CC && lastw==PIXY_START_WORD)
	{
      blockType = CC_BLOCK;
      return true;
	}
	else if (w==PIXY_START_WORDX)
	{
	  Serial.println("reorder");
	  link.getByte(); // resync
	}
	lastw = w; 
  }
}

template <class LinkType> void TPixy<LinkType>::resize()
{
  blockArraySize += PIXY_INITIAL_ARRAYSIZE;
  blocks = (Block *)realloc(blocks, sizeof(Block)*blockArraySize);
}  
		
template <class LinkType> uint16_t TPixy<LinkType>::getBlocks(uint16_t maxBlocks)
{
  uint8_t i;
  uint16_t w, checksum, sum;
  Block *block;
  
  if (!skipStart)
  {
    if (getStart()==false)
      return 0;
  }
  else
	skipStart = false;
	
  for(blockCount=0; blockCount<maxBlocks && blockCount<PIXY_MAXIMUM_ARRAYSIZE;)
  {
    checksum = link.getWord();
    if (checksum==PIXY_START_WORD) // we've reached the beginning of the next frame
    {
      skipStart = true;
	  blockType = NORMAL_BLOCK;
	  //Serial.println("skip");
      return blockCount;
    }
	else if (checksum==PIXY_START_WORD_CC)
	{
	  skipStart = true;
	  blockType = CC_BLOCK;
	  return blockCount;
	}
    else if (checksum==0)
      return blockCount;
    
	if (blockCount>blockArraySize)
		resize();
	
	block = blocks + blockCount;
	
    for (i=0, sum=0; i<sizeof(Block)/sizeof(uint16_t); i++)
    {
	  if (blockType==NORMAL_BLOCK && i>=5) // skip 
	  {
		block->angle = 0;
		break;
	  }
      w = link.getWord();
      sum += w;
      *((uint16_t *)block + i) = w;
    }

    if (checksum==sum)
      blockCount++;
    else
      Serial.println("cs error");
	
	w = link.getWord();
	if (w==PIXY_START_WORD)
	  blockType = NORMAL_BLOCK;
	else if (w==PIXY_START_WORD_CC)
	  blockType = CC_BLOCK;
	else
      return blockCount;
  }
}

template <class LinkType> int8_t TPixy<LinkType>::setServos(uint16_t s0, uint16_t s1)
{
  uint8_t outBuf[6];
   
  outBuf[0] = 0x00;
  outBuf[1] = 0xff; 
  *(uint16_t *)(outBuf + 2) = s0;
  *(uint16_t *)(outBuf + 4) = s1;
  
  return link.send(outBuf, 6);
}

template <class LinkType> int8_t TPixy<LinkType>::setBrightness(uint8_t brightness)
{
  uint8_t outBuf[3];
   
  outBuf[0] = 0x00;
  outBuf[1] = 0xfe; 
  outBuf[2] = brightness;
  
  return link.send(outBuf, 3);
}

template <class LinkType> int8_t TPixy<LinkType>::setLED(uint8_t r, uint8_t g, uint8_t b)
{
  uint8_t outBuf[5];
  
  outBuf[0] = 0x00;
  outBuf[1] = 0xfd; 
  outBuf[2] = r;
  outBuf[3] = g;
  outBuf[4] = b;
  
  return link.send(outBuf, 5);
}

#endif

Python webserver auxiliary file: participationater.py

Python
Needed for server.py; provides most of the actual functionality; place in same directory as server.py
# This is a sample Python script.

# Press R to execute it or replace it with your code.
# Press Double  to search everywhere for classes, files, tool windows, actions, and settings.


import sqlite3
import argparse
import requests
import sys
import datetime
import os

#ipaddr = '192.168.10.42'
#port = '80'

silentperiod = True

n_seen = 0
n_notseen = 0
nsperiodlen = 0
stop_listen = False


def _parse_args():
    parser = argparse.ArgumentParser(description="RISE with multiple modes of operation")

    parser.add_argument("mode", choices=["init", "add-students", "delete-students", "main", "class-list"], help="Operation mode")
    parser.add_argument("--dbname", help="Name of the database", default="test0.db")
    parser.add_argument("--classname", help="Name of class", required=False)
    parser.add_argument("--device_ip", help="IP addr of RISE device", default="192.168.10.42", required=False)
    parser.add_argument("--device_port", help="PORT of RISE device", default="80", required=False)

    args = parser.parse_args()
    return args


def dbcc(name):
    if os.path.exists(name):
        conn = sqlite3.connect(name,check_same_thread=False)
        c=conn.cursor()
    else:
        c,conn = dbinit(name)
    return c,conn


# Create table for students and handraises
def dbinit(name):
    if os.path.exists(name):
        os.remove(name)

    conn = sqlite3.connect(name)
    c = conn.cursor()
    c.execute('''CREATE TABLE class_student
             (class_name TEXT,
             student_name TEXT,
             signature INTEGER PRIMARY KEY,
             UNIQUE (student_name, class_name))''')

    # Create table for handRaises
    c.execute('''CREATE TABLE handRaises
             (raise_id INTEGER PRIMARY KEY AUTOINCREMENT,
             signature INTEGER,
             raise_time TIMESTAMP,
             class_name TEXT,
             FOREIGN KEY(signature, class_name) REFERENCES class_student(signature, class_name))''')

    # Commit the changes
    conn.commit()
    return c, conn

def delete_student(c, student_name = None, class_name=None):
    global conn
    """
    Deletes a student from the class_student table.

    Args:
        c: db cursor.
        student_name Optional(str): The class name.
        class_name Optional(str): The class name.

    Returns:
        None.
    """
    if student_name is None:
        student_name = input("Enter the student's name (or 'EXIT' to exit, or 'ALL' for all students): ")
    if student_name == 'EXIT':
        return False
    if class_name is None:
        class_name = input("Enter the class name: ")
    # Delete the student from the class_student table
    if student_name=='ALL':
        c.execute("DELETE FROM class_student WHERE class_name = ?", (class_name,))
    else:
        c.execute("DELETE FROM class_student WHERE student_name = ? AND class_name = ?", (student_name, class_name))

    # Commit the changes
    conn.commit()
    return True

def add_student(c, student_name = None, class_name = None, signature=None):
    """
    Adds student to database. Prompts the user to input a student's name, class name, and signature as necessary.

    Args:
        c: db cursor.
        student_name Optional(str): The class name.
        class_name Optional(str): The class name.

    """
    # Prompt the user to input the student's information if necessary
    global conn
    if student_name is None:
        student_name = input("Enter the student's name (or 'EXIT' to exit): ")
    if student_name=='EXIT':
        return False
    if class_name is None:
        class_name = input("Enter the class name: ")
    if signature is None:
        signature = int(input("Enter the student's signature (a 3-digit number): "))
    c.execute("SELECT 1 FROM class_student WHERE signature = ? AND class_name = ? and student_name != ?",
              (signature, class_name, student_name))
    if c.fetchone() is not None:
        print(f"Signature {signature} is already in use by another student in {class_name}.")
        return True

    # Insert the student into the class_student table or update their signature if they already exist
    c.execute("INSERT OR REPLACE INTO class_student (student_name, class_name, signature) VALUES (?, ?, ?)",
              (student_name, class_name, signature))

    # Commit the changes
    conn.commit()
    return True

def handleblock(c, signature, ang, secs, class_name, minmsgs=4, reset=False):
    # takes a (signature, ang, secs) tuple, determines whether it is a hand-raising period (eg teacher asked question)
    # and if, determines who raised their hand during the period and saves it into the database
    global silentperiod, n_seen, n_notseen, nsperiodlen
    if not hasattr(handleblock, 'cd'):
        handleblock.cd = {}
    if reset:
        handleblock.cd = {}
        return
    if silentperiod:
        if signature > 0:
            n_seen = n_seen + 1
        if n_seen > minmsgs:
            silentperiod = False
            print(f"silentperiod ended: {datetime.datetime.now(): %H:%M:%S}")
            handleblock.cd = {}
            nsperiodlen = minmsgs
            n_seen = 0
    if not silentperiod:
        if signature < 0:
            n_notseen = n_notseen + 1
        else:
            nsperiodlen = nsperiodlen + 1
            if signature in handleblock.cd:
                handleblock.cd[signature] = handleblock.cd[signature] + 1
            else:
                handleblock.cd[signature] = 1

        if n_notseen > minmsgs:
            n_notseen = 0
            silentperiod = True
            print(f"silentperiod begin: {datetime.datetime.now(): %H:%M:%S}")
            who_raised(c, handleblock.cd, nsperiodlen, class_name)
            report_stats(c, class_name)

    #print("is a silentperiod?", silentperiod)



def classlist(c, class_name=None):
    # prints class list for class_name
    global conn
    if class_name is not None:
        c.execute('''
                SELECT student_name, class_name, signature
                FROM class_student 
                WHERE class_name = ?
                ''', (class_name,))
    else:
        c.execute('''SELECT student_name, class_name, signature
                        FROM class_student''')


    # Fetch the results and print the report
    rows = c.fetchall()
    if rows:
        print(f"Class list:")
        for student_name, classname, signature in rows:
            print(f"{student_name}, {classname}: {signature}")
    else:
        print("No students found.")

def raiseslist(c, class_name=None):
    # prints data in database for handraises that have been detected for class_name
    global conn
    if class_name is not None and len(class_name)>0:
        c.execute('''
                SELECT signature, raise_time, class_name
                FROM handRaises
                WHERE class_name = ?
                ''', (class_name,))
    else:
        c.execute('''
                SELECT signature, raise_time, class_name
                FROM handRaises''')


    # Fetch the results and print the report
    rows = c.fetchall()
    if rows:
        print(f"Handraises detected (signature, class, raise_time):")
        for signature,raise_time,classname in rows:
            if len(classname)>0:
                print(f"{signature}, {classname}, {raise_time}")
    else:
        print("No raises found.")

def report_stats(c, class_name):
    date = datetime.datetime.now()
    """
    Prints a report showing the number of hand raises each student in a class has on a day.

    Args:
        c: db cursor
        class_name (str): The class name

    Returns:
        None.
    """
    # Define the start and end times of the day
    # start_time = datetime.datetime(date.year, date.month, date.day, 0, 0, 0)
    end_time = datetime.datetime(date.year, date.month, date.day, 23, 59, 59)

    # Use a JOIN to count the hand raises on that day for each student in the class
    # c.execute('''
    #     SELECT class_student.student_name, COUNT(handRaises.raise_id)
    #     FROM handRaises
    #     JOIN class_student
    #     ON handRaises.signature = class_student.signature
    #     WHERE class_student.class_name = ?
    #     AND handRaises.raise_time BETWEEN ? AND ?
    #     GROUP BY class_student.student_name
    # ''', (class_name, start_time, end_time))

    c.execute('''
            SELECT class_student.student_name, COUNT(handRaises.signature)
            FROM class_student
            LEFT JOIN handRaises ON class_student.signature = handRaises.signature
            AND DATE(handRaises.raise_time) = DATE(?) and class_student.class_name=handRaises.class_name
            WHERE class_student.class_name = ? 
            GROUP BY class_student.student_name
            ORDER BY COUNT(handRaises.signature) ASC
        ''', (end_time,class_name))


    # Fetch the results and print the report
    rows = c.fetchall()
    if rows:
        print(f"Hand raise report for {class_name} on {date: %Y-%m-%d %H:%M:%S}:")
        for student_name, count in rows:
            print(f"{student_name}: {count} hand raises")
    else:
        print(f"No hand raises in {class_name} on {date: %Y-%m-%d %H:%M:%S}")


def who_raised(c, cd, tot, class_name, mul=5):
    # determines who in the count dictionary, cd, has enough time raising hand to be counted
    # use mul to say that a student is counted if his hand-raising detections is 1/mul of the total possible
    raise_time = datetime.datetime.now()
    for k in cd:
        if cd[k] * mul > tot:
            record_handraise(c, k, class_name, raise_time)


def record_handraise(c, signature, class_name, raise_time):
    # save handraise event to the database
    global conn
    c.execute("INSERT INTO handRaises (signature, raise_time, class_name) VALUES (?, ?, ?)", (signature, raise_time, class_name))
    conn.commit()


def process_event(c, event, class_name):
    #parses the event message coming from the hardware device over the ip address
    if len(event) == 1:
        return
    if len(event.split(',')) == 3:
        event = event[6:]
        sigstr, angstr, secstr = event.split(',')
        sig = int(float(sigstr.split(':')[1]))
        ang = int(float(angstr.split(':')[1]))
        secs = float(secstr.split(':')[1])
        handleblock(c, sig, ang, secs, class_name)
        #print(f"Received event {datetime.datetime.now():%H:%M:%S}: {sig},{ang},{secs}, {class_name}")
    else:
        pass
        #print(f"Received unparseable event {datetime.datetime.now():%H:%M:%S}: {event}, {len(event)}, {class_name}")


def listen_to_events(url, class_name):
    # Connect to the device output server at url
    c=conn.cursor()
    response = requests.get(url, stream=True)

    buffer = ""
    for chunk in response.iter_content(chunk_size=1):  # Read one byte at a time
        if chunk:  # skip keep-alive newlines
            decoded_chunk = chunk.decode('utf-8')
            if decoded_chunk == '\n':
                process_event(c, buffer, class_name)
                buffer = ""
            else:
                buffer += decoded_chunk
        if stop_listen:
            print('Stop signal detected')
            handleblock(c,-1,0,0,None, reset=True)
            break


#listen_to_events(f"http://{ipaddr}:{port}/events")

# Press the green button in the gutter to run the script.
if __name__ == '__main__':
    # could run this code from command line instead of the web interface
    args = _parse_args()

    if args.mode == "init":
        c,conn=dbinit(args.dbname)
        exit()

    conn = dbcc(args.dbname)
    c=conn.cursor()
    if args.mode == "add-students":
        while add_student(c,class_name = args.classname):
            pass
        classlist(args.classname)
    elif args.mode == "delete-students":
        while delete_student(c, class_name = args.classname):
            pass
    elif args.mode=="class-list":
        classlist(c, args.classname)
    elif args.mode == "main":
        assert args.classname is not None, "Must specify a class"
        listen_to_events(f"http://{args.device_ip}:{args.device_port}/events", args.classname)


# See PyCharm help at https://www.jetbrains.com/help/pycharm/

HTML for Python webserver: index.html

HTML
must be placed in a folder called serverdir/templates, where server.py resides in serverdir
<!DOCTYPE html>
<html>
<head>
    <title>Participationater Management</title>
    <style>
        .header {
            display: flex;
            align-items: center;  /* This will vertically align the items in the middle */
            justify-content: center; /* This will horizontally align the items in the middle */
        }
        .logo {
            width: 100px;  /* Adjust these values to get the size you want */
            height: 100px;
            margin-right: 10px; /* Adds some spacing between the logo and the title */
        }
        body {
            font-family: Arial, sans-serif;
            background-color: #f0f8ff;
            margin: 10px;
            padding: 10px;
        }

        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
        }

        h1 {
            text-align: center;
            color: #333;
        }

        form {
            background-color: #fff;
            border: 1px solid #ddd;
            padding: 20px;
            border-radius: 5px;
            box-shadow: 0px 0px 10px rgba(0,0,0,0.1);
        }

        form input[type="text"],
        form input[type="number"] {
            width: 80%;
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }

        form input[type="submit"] {
            background-color: #0099cc;
            color: #fff;
            border: none;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
        }

        form input[type="submit"]:hover {
            background-color: #007399;
        }

        .output {
            margin-top: 20px;
            background-color: #eee;
            padding: 20px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }

        .output textarea {
            width: 97%;
            height: 400px;
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 5px;
            border: 1px solid #ddd;
        }
    </style>
</head>
<body>
    <div class="header">
        <img class="logo" src="{{ url_for('static', filename='riselogo.png') }}" alt="Logo">&nbsp
        <h1>Classroom Manager</h1>
    </div>
    <p>Database connected to: {{ dbname }}
     <p>Hardware IP Address: {{ hardwareip }}</p> <button id="go-button">Class Monitor Tab</button><button id="device-button">Device Tab</button><br><br>
    <form method="POST">
        <input type="text" name="class_name" placeholder="Class Name" value="{{ class_name }}">
        <input type="text" name="student_name" placeholder="Student Name">
        <input type="text" name="signature" placeholder="Color Signature"><br><br>
        <button type="submit" name="add_student">Add Student</button>
        <button type="submit" name="delete_student">Delete Student</button>
        <button type="submit" name="show_class">Show Class List</button>
        <button type="submit" name="show_student">Show Student</button>
        <button type="submit" name="show_raises">Show Handraises</button><Br><br>
        <button type="submit" name="startmonitor">StartMonitor</button>
        <button type="submit" name="stopmonitor">StopMonitor</button>
        <button type="submit" name="refresh">Refresh</button><br><br>
    </form>
    <div class="output">
        <textarea rows="100" readonly>{{ stdout_string }}</textarea>
    </div>
    <script>
        document.getElementById('go-button').onclick = function() {
            var className = document.getElementsByName('class_name')[0].value;
            if (className === '') {
                alert('Please enter a class name');
            } else {
                var win = window.open('/class/' + className, className);
                win.focus();
            }
        };
        document.getElementById('device-button').onclick = function() {
            var ip_address = '{{ hardwareip }}';  // Fetch the IP address from Jinja2
            var win = window.open('http://' + ip_address, 'risedevice');
            win.focus()
        };
    </script>
</body>
</html>

HTML for Python webserver: classtable.html

HTML
place in serverdir/templates directory where serverdir contains server.py
<!DOCTYPE html>
<html>
<head>
    <title>{{ class_name }} Handraises for {{ datestr }}</title>
    <meta http-equiv="refresh" content="10"> <!-- This will refresh the page every 10 seconds -->
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f0f8ff;
        }
        table {
            border-collapse: collapse;
            background-color: white;
            margin: auto;
        }

        th, td {
            border: 1px solid black;
            padding: 8px;
            text-align: left;
        }
         h1, h2, h4{
            text-align: center;
        }
        .back-button {
            text-align: center;
        }
        .button-container {
            text-align: center;
        }
        .header {
            display: flex;
            align-items: center;  /* This will vertically align the items in the middle */
            justify-content: center; /* This will horizontally align the items in the middle */
        }
        .logo {
            width: 100px;  /* Adjust these values to get the size you want */
            height: 100px;
            margin-right: 10px; /* Adds some spacing between the logo and the title */
        }
    </style>
</head>
<body>
    <div class="header">
        <img class="logo" src="{{ url_for('static', filename='riselogo.png') }}" alt="Logo">&nbsp
        <h1>{{ class_name }} Handraises for {{ datestr }}</h1>
    </div>
    <h4>{{ listening_status }}</h4>
    <table>
        <tr>
            <th>Student Name</th>
            <th>Handraises</th>
        </tr>
        {% for student in students %}
        <tr>
            <td>{{ student[0] }}</td>
            <td>{{ student[1] }}</td>
        </tr>
        {% endfor %}
    </table><br><br>
    <div class="button-container">
        <a href="{{ url_for('home') }}" class="back-button">Back</a>
    </div>
</body>
</html>

Recommended way to get code: Github repo to RISE

Arduino IDE .ino file for upload to FireBeetle ESP32 and python code to run a Flask web server on any computer to manage the system.

Credits

James Ooi

James Ooi

2 projects • 2 followers
Benji Ooi

Benji Ooi

1 project • 1 follower
Oscar Ooi

Oscar Ooi

1 project • 0 followers
Matthew McGowan

Matthew McGowan

0 projects • 0 followers

Comments