From 93f68bca5af758ac644ebb070332494d5c867e38 Mon Sep 17 00:00:00 2001
From: Brice Copy <500789+bcopy@users.noreply.github.com>
Date: Sun, 13 Oct 2024 02:03:56 +0200
Subject: [PATCH] Add question display
---
doc/homie-device-hierarchy.yaml | 11 +-
package-lock.json | 35 ++++++-
package.json | 9 +-
public/questions/vote-config.yaml | 32 ++++++
public/quizz.html | 2 +
src/bin/create-vote-device.js | 113 +++++++++++++++++++++
src/quizz-scene.js | 4 +-
src/scene-quizz/QuizzSceneController.js | 79 +++++++++++---
src/scene-quizz/questionDisplayTemplate.js | 22 ++++
9 files changed, 286 insertions(+), 21 deletions(-)
create mode 100644 public/questions/vote-config.yaml
create mode 100755 src/bin/create-vote-device.js
create mode 100644 src/scene-quizz/questionDisplayTemplate.js
diff --git a/doc/homie-device-hierarchy.yaml b/doc/homie-device-hierarchy.yaml
index 77e9642..a9167ca 100644
--- a/doc/homie-device-hierarchy.yaml
+++ b/doc/homie-device-hierarchy.yaml
@@ -269,8 +269,8 @@ devices:
name: Display Luminosity
datatype: integer
unit: "%"
- vote:
- name: Voting round
+ vote-X:
+ name: Voting round X
state: ready
nodes:
config:
@@ -283,9 +283,10 @@ devices:
question-statement:
name: Question Statement
datatype: string
- voting-open:
- name: Voting Status
- datatype: boolean
+ state:
+ name: Voting state
+ datatype: enum
+ format: ready,active,finished
total-votes:
name: Total Votes Cast
datatype: integer
diff --git a/package-lock.json b/package-lock.json
index 1079a34..222cb86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,13 +11,16 @@
"@cmcrobotics/homie-lit": "^0.7.3",
"@tweenjs/tween.js": "25.0.0",
"aframe": "^1.6.0",
+ "aframe-draw-component": "^1.5.0",
"aframe-extras": "^7.5.1",
"aframe-htmlembed-component": "^1.0.0",
"aframe-label": "^0.1.3",
"aframe-orbit-controls": "^1.3.2",
+ "aframe-textwrap-component": "^1.0.1",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"https-browserify": "^1.0.0",
+ "js-yaml": "^4.1.0",
"lit-html": "^3.2.1",
"loglevel": "^1.9.2",
"mqtt": "^4.3.7",
@@ -30,7 +33,8 @@
"uuid": "^10.0.0"
},
"bin": {
- "create-test-players": "src/utils/create-test-players.js"
+ "create-test-players": "src/bin/create-test-players.js",
+ "create-vote-device": "src/bin/create-vote-device.js"
},
"devDependencies": {
"copy-webpack-plugin": "^12.0.2",
@@ -738,6 +742,11 @@
"npm": ">= 2.15.9"
}
},
+ "node_modules/aframe-draw-component": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/aframe-draw-component/-/aframe-draw-component-1.5.0.tgz",
+ "integrity": "sha512-An83kmdxWxInlcQwbiiiYm2x5HkeVZDQTCBXtuC5Rd/1AdkDzKqSzOhtqz6n5TTP9zlFlQcwAfFnwTiK8VCraQ=="
+ },
"node_modules/aframe-extras": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/aframe-extras/-/aframe-extras-7.5.1.tgz",
@@ -767,6 +776,12 @@
"integrity": "sha512-j4e14ko3ELmfMF8FWEXtVSqSObYtoJpyhyZ9l2HY23iLY3Z7qmT/kRJ24wDQT/CEmToi3a9j7MqUeLDcbLALDA==",
"license": "MIT"
},
+ "node_modules/aframe-textwrap-component": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/aframe-textwrap-component/-/aframe-textwrap-component-1.0.1.tgz",
+ "integrity": "sha512-+4jg+QH2btXi19nSh8mmqoZP6DESh1Gb6GOVn3ZwXIkfig6O+ZICNiLEdQ9nuDYjvc37UwXhjcSm7M2TQq8bTA==",
+ "license": "MIT"
+ },
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -867,6 +882,12 @@
"node": ">= 8"
}
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/arguments-extended": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/arguments-extended/-/arguments-extended-0.0.3.tgz",
@@ -3613,6 +3634,18 @@
"url": "https://opencollective.com/js-sdsl"
}
},
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
diff --git a/package.json b/package.json
index 5afe21e..222d92b 100644
--- a/package.json
+++ b/package.json
@@ -8,19 +8,23 @@
"build": "webpack --mode production"
},
"bin": {
- "create-test-players": "./src/bin/create-test-players.js"
+ "create-test-players": "./src/bin/create-test-players.js",
+ "create-vote-device": "./src/bin/create-vote-device.js"
},
"dependencies": {
"@cmcrobotics/homie-lit": "^0.7.3",
"@tweenjs/tween.js": "25.0.0",
"aframe": "^1.6.0",
+ "aframe-draw-component": "^1.5.0",
"aframe-extras": "^7.5.1",
"aframe-htmlembed-component": "^1.0.0",
"aframe-label": "^0.1.3",
"aframe-orbit-controls": "^1.3.2",
+ "aframe-textwrap-component": "^1.0.1",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"https-browserify": "^1.0.0",
+ "js-yaml": "^4.1.0",
"lit-html": "^3.2.1",
"loglevel": "^1.9.2",
"mqtt": "^4.3.7",
@@ -29,7 +33,8 @@
"reveal.js": "^5.1.0",
"rxjs": "^7.8.0",
"stream-http": "^3.2.0",
- "url": "^0.11.4"
+ "url": "^0.11.4",
+ "uuid": "^10.0.0"
},
"devDependencies": {
"copy-webpack-plugin": "^12.0.2",
diff --git a/public/questions/vote-config.yaml b/public/questions/vote-config.yaml
new file mode 100644
index 0000000..027e05e
--- /dev/null
+++ b/public/questions/vote-config.yaml
@@ -0,0 +1,32 @@
+questions:
+ - question-id: Q1
+ question-statement: "What is the capital of France?"
+ option-1: "London"
+ option-2: "Berlin"
+ option-3: "Paris"
+ option-4: "Madrid"
+ correct-option: 3
+
+ - question-id: Q2
+ question-statement: "Which planet is known as the Red Planet?"
+ option-1: "Venus"
+ option-2: "Mars"
+ option-3: "Jupiter"
+ option-4: "Saturn"
+ correct-option: 2
+
+ - question-id: Q3
+ question-statement: "Who painted the Mona Lisa?"
+ option-1: "Vincent van Gogh"
+ option-2: "Pablo Picasso"
+ option-3: "Leonardo da Vinci"
+ option-4: "Michelangelo"
+ correct-option: 3
+
+ - question-id: Q4
+ question-statement: "Some Super Long Question that makes little sense but it's okay it'll be fine and then "
+ option-1: "Long long long answer that's just very very long just fine fine fine just fine fine fine and then some more"
+ option-2: "Long long long answer that's just very very long just fine fine fine just fine fine fine and then some more"
+ option-3: "Long long long answer that's just very very long just fine fine fine just fine fine fine and then some more"
+ option-4: "Long long long answer that's just very very long just fine fine fine just fine fine fine and then some more"
+ correct-option: 3
\ No newline at end of file
diff --git a/public/quizz.html b/public/quizz.html
index e279963..e2b75ca 100644
--- a/public/quizz.html
+++ b/public/quizz.html
@@ -42,6 +42,8 @@
+
+
diff --git a/src/bin/create-vote-device.js b/src/bin/create-vote-device.js
new file mode 100755
index 0000000..81963ca
--- /dev/null
+++ b/src/bin/create-vote-device.js
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+
+const mqtt = require('mqtt');
+const { v4: uuidv4 } = require('uuid');
+const yaml = require('js-yaml');
+const fs = require('fs');
+
+// MQTT broker URL - replace with your actual MQTT broker URL
+const brokerUrl = 'mqtt://localhost:1883';
+
+// Function to read YAML file
+function readYamlFile(filePath) {
+ try {
+ const fileContents = fs.readFileSync(filePath, 'utf8');
+ const data = yaml.load(fileContents);
+ return data;
+ } catch (e) {
+ console.log(e);
+ return null;
+ }
+}
+
+// Function to create and publish vote device
+function createVoteDevice(question) {
+ const client = mqtt.connect(brokerUrl);
+
+ client.on('connect', () => {
+ console.log('Connected to MQTT broker');
+
+ const deviceId = `vote-${uuidv4()}`;
+ const baseTopic = 'homie/'+deviceId;
+
+ // Publish device properties
+ client.publish(`${baseTopic}/$homie`, '4.0', { retain: true });
+ client.publish(`${baseTopic}/$name`, `Vote ${question['question-id']}`, { retain: true });
+ client.publish(`${baseTopic}/$nodes`, 'config', { retain: true });
+
+ // Publish config node properties
+ client.publish(`${baseTopic}/config/$name`, 'Vote Configuration', { retain: true });
+ client.publish(`${baseTopic}/config/$type`, 'vote-config', { retain: true });
+ client.publish(`${baseTopic}/config/$properties`, 'question-id,question-statement,option-1,option-2,option-3,option-4,correct-option,state', { retain: true });
+
+ // Publish question-id property
+ client.publish(`${baseTopic}/config/question-id`, question['question-id'], { retain: true });
+ client.publish(`${baseTopic}/config/question-id/$name`, 'Question ID', { retain: true });
+ client.publish(`${baseTopic}/config/question-id/$datatype`, 'string', { retain: true });
+ client.publish(`${baseTopic}/config/question-id/$settable`, 'false', { retain: true });
+
+ // Publish question-statement property
+ client.publish(`${baseTopic}/config/question-statement`, question['question-statement'], { retain: true });
+ client.publish(`${baseTopic}/config/question-statement/$name`, 'Question Statement', { retain: true });
+ client.publish(`${baseTopic}/config/question-statement/$datatype`, 'string', { retain: true });
+ client.publish(`${baseTopic}/config/question-statement/$settable`, 'false', { retain: true });
+
+ // Publish individual option properties
+ for (let i = 1; i <= 4; i++) {
+ const optionKey = `option-${i}`;
+ client.publish(`${baseTopic}/config/${optionKey}`, question[optionKey], { retain: true });
+ client.publish(`${baseTopic}/config/${optionKey}/$name`, `Option ${i}`, { retain: true });
+ client.publish(`${baseTopic}/config/${optionKey}/$datatype`, 'string', { retain: true });
+ client.publish(`${baseTopic}/config/${optionKey}/$settable`, 'false', { retain: true });
+ }
+
+ // Publish correct-option property
+ client.publish(`${baseTopic}/config/correct-option`, question['correct-option'].toString(), { retain: true });
+ client.publish(`${baseTopic}/config/correct-option/$name`, 'Correct Option', { retain: true });
+ client.publish(`${baseTopic}/config/correct-option/$datatype`, 'integer', { retain: true });
+ client.publish(`${baseTopic}/config/correct-option/$settable`, 'false', { retain: true });
+ client.publish(`${baseTopic}/config/correct-option/$format`, '1:4', { retain: true });
+
+ // Publish state property
+ client.publish(`${baseTopic}/config/state`, 'ready', { retain: true });
+ client.publish(`${baseTopic}/config/state/$name`, 'State', { retain: true });
+ client.publish(`${baseTopic}/config/state/$datatype`, 'enum', { retain: true });
+ client.publish(`${baseTopic}/config/state/$settable`, 'true', { retain: true });
+ client.publish(`${baseTopic}/config/state/$format`, 'ready,active,finished', { retain: true });
+
+ console.log(`Vote device created with ID: ${deviceId}`);
+ console.log('Question ID:', question['question-id']);
+ console.log('Question Statement:', question['question-statement']);
+ console.log('Options:');
+ for (let i = 1; i <= 4; i++) {
+ console.log(` Option ${i}:`, question[`option-${i}`]);
+ }
+ console.log('Correct Option:', question['correct-option']);
+
+ client.end();
+ });
+
+ client.on('error', (error) => {
+ console.error('Error:', error);
+ client.end();
+ });
+}
+
+// Main execution
+const args = process.argv.slice(2);
+if (args.length !== 2) {
+ console.log('Usage: node create-vote-device.js ');
+ process.exit(1);
+}
+
+const yamlFilePath = args[0];
+const questionIndex = parseInt(args[1]);
+
+const voteConfig = readYamlFile(yamlFilePath);
+if (!voteConfig || !voteConfig.questions || questionIndex >= voteConfig.questions.length) {
+ console.log('Invalid YAML file or question index');
+ process.exit(1);
+}
+
+const selectedQuestion = voteConfig.questions[questionIndex];
+createVoteDevice(selectedQuestion);
\ No newline at end of file
diff --git a/src/quizz-scene.js b/src/quizz-scene.js
index 7d97139..e7cd067 100644
--- a/src/quizz-scene.js
+++ b/src/quizz-scene.js
@@ -9,6 +9,8 @@ import './components/look-towards';
import 'aframe-extras';
import 'aframe-label';
import 'aframe-htmlembed-component';
+import 'aframe-draw-component';
+import 'aframe-textwrap-component';
// Polyfill global Buffer
@@ -18,6 +20,6 @@ window.Buffer = Buffer;
AFRAME.registerComponent('scene-controller', {
init: function() {
- this.controller = new QuizzSceneController('ws://localhost:9001', 'teams');
+ this.controller = new QuizzSceneController('ws://localhost:9001', 'teams', 'questions');
}
});
\ No newline at end of file
diff --git a/src/scene-quizz/QuizzSceneController.js b/src/scene-quizz/QuizzSceneController.js
index 6dea575..ddeb99a 100644
--- a/src/scene-quizz/QuizzSceneController.js
+++ b/src/scene-quizz/QuizzSceneController.js
@@ -1,8 +1,10 @@
import { createMqttHomieObserver, HomiePropertyBuffer, logger as homieLogger } from '@cmcrobotics/homie-lit';
import PropertyUpdate from '../homie-lit-components/PropertyUpdate';
import TeamLayout from '../homie-lit-components/TeamLayout';
+import { questionDisplayTemplate } from './questionDisplayTemplate';
import * as nools from 'nools';
import log from 'loglevel';
+import { render } from 'lit-html';
class PlayerNode {
constructor(deviceId, nodeId, properties) {
@@ -13,17 +15,16 @@ class PlayerNode {
}
class QuizzSceneController {
- constructor(brokerUrl, parentElementId, mqttOptions = {}) {
+ constructor(brokerUrl, teamParentElementId, questionParentElementId, mqttOptions = {}) {
this.setupLogging();
this.homieObserver = createMqttHomieObserver(brokerUrl, mqttOptions);
- // this.homieObserver.subscribe("gateway/#");
- // this.homieObserver.subscribe("terminal-+/#");
this.homieObserver.subscribe("#");
- this.parentElement = document.getElementById(parentElementId);
+ this.teamParentElement = document.getElementById(teamParentElementId);
+ this.questionParentElement = document.getElementById(questionParentElementId);
this.propertyBuffer = new HomiePropertyBuffer(this.homieObserver, 300);
this.players = new Map();
this.terminalToPlayerMap = new Map();
- this.teamLayout = new TeamLayout(this.parentElement);
+ this.teamLayout = new TeamLayout(this.teamParentElement);
this.flow = this.initNoolsFlow();
this.session = this.flow.getSession();
@@ -31,6 +32,10 @@ class QuizzSceneController {
this.setupObservers();
this.teamLayout.createTeamLayouts();
+ this.currentVote = null;
+ this.questionDisplayElement = document.createElement('a-entity');
+ this.questionDisplayElement.setAttribute('id', 'questionDisplay');
+ this.questionParentElement.appendChild(this.questionDisplayElement);
}
setupLogging() {
@@ -48,13 +53,23 @@ class QuizzSceneController {
handlePropertyUpdate(update);
}
}
- `,{
- define:{
- PropertyUpdate : PropertyUpdate,
+
+ rule ProcessVoteUpdate {
+ when {
+ update: PropertyUpdate update.deviceId.startsWith('vote-')
+ }
+ then {
+ handleVoteUpdate(update);
+ }
+ }
+ `, {
+ define: {
+ PropertyUpdate: PropertyUpdate,
PlayerNode: PlayerNode
},
scope: {
handlePropertyUpdate: this.handlePropertyUpdate.bind(this),
+ handleVoteUpdate: this.handleVoteUpdate.bind(this),
logger: console
},
name: "quizz"
@@ -83,7 +98,7 @@ class QuizzSceneController {
handlePropertyUpdate(update) {
if (this.isMetaProperty(update.propertyId)) {
- return; // Ignore meta properties
+ return;
}
let player = this.players.get(update.nodeId);
@@ -108,7 +123,50 @@ class QuizzSceneController {
}
}
+ handleVoteUpdate(update) {
+ if (this.isMetaProperty(update.propertyId)) {
+ return;
+ }
+
+ if (!this.currentVote || this.currentVote.deviceId !== update.deviceId) {
+ this.currentVote = { deviceId: update.deviceId, properties: {} };
+ }
+ this.currentVote.properties[update.propertyId] = update.value;
+
+ if (update.propertyId === 'question-statement' ||
+ update.propertyId === 'option-1' ||
+ update.propertyId === 'option-2' ||
+ update.propertyId === 'option-3' ||
+ update.propertyId === 'option-4') {
+ this.updateQuestionDisplay();
+ }
+
+ log.debug(`Updating vote property: ${update.deviceId}/${update.propertyId} = ${update.value}`);
+ }
+
+ updateQuestionDisplay() {
+ if (this.currentVote &&
+ this.currentVote.properties['question-statement'] &&
+ this.currentVote.properties['option-1'] &&
+ this.currentVote.properties['option-2'] &&
+ this.currentVote.properties['option-3'] &&
+ this.currentVote.properties['option-4']) {
+
+ const question = this.currentVote.properties['question-statement'];
+ const options = [
+ this.currentVote.properties['option-1'],
+ this.currentVote.properties['option-2'],
+ this.currentVote.properties['option-3'],
+ this.currentVote.properties['option-4']
+ ];
+
+ render(questionDisplayTemplate(question, options), this.questionDisplayElement);
+
+ log.debug(`Updated question display: ${question}`);
+ log.debug(`Options: ${options.join(', ')}`);
+ }
+ }
updatePlayerAnimation(player) {
const playerEntity = this.parentElement.querySelector(`#${player.nodeId}`);
@@ -119,18 +177,15 @@ class QuizzSceneController {
}
updateTerminalToPlayerMap(player, terminalId) {
- // Remove old mapping if exists
for (let [key, value] of this.terminalToPlayerMap) {
if (value === player.nodeId) {
this.terminalToPlayerMap.delete(key);
break;
}
}
- // Add new mapping
this.terminalToPlayerMap.set(terminalId, player.nodeId);
log.debug(`Updated terminal-to-player mapping: Terminal ${terminalId} -> Player ${player.nodeId}`);
}
-
}
export default QuizzSceneController;
\ No newline at end of file
diff --git a/src/scene-quizz/questionDisplayTemplate.js b/src/scene-quizz/questionDisplayTemplate.js
new file mode 100644
index 0000000..9ce068e
--- /dev/null
+++ b/src/scene-quizz/questionDisplayTemplate.js
@@ -0,0 +1,22 @@
+import { html } from 'lit-html';
+
+export const questionDisplayTemplate = (question, options) => html`
+
+
+ ${options.map((option, index) => html`
+
+
+
+
+
+
+
+
+ `)}
+
+`;
\ No newline at end of file