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