From 4502662a535697dfc6c4e43f9384974494e98a1e Mon Sep 17 00:00:00 2001 From: Anton Franzluebbers Date: Sun, 3 Jul 2022 18:51:51 -0400 Subject: [PATCH] add velconnect unity package, lots of api changes to make it more generic, added dockerfile --- .github/workflows/ci.yml | 19 + unity_package/.gitignore | 0 unity_package/Editor.meta | 8 + unity_package/Runtime.meta | 8 + unity_package/Runtime/VEL-Connect.asmdef | 18 + unity_package/Runtime/VEL-Connect.asmdef.meta | 7 + unity_package/Runtime/VELConnectManager.cs | 349 ++++++++++++++++++ .../Runtime/VELConnectManager.cs.meta | 11 + unity_package/package.json | 17 + unity_package/package.json.meta | 7 + velconnect/CreateDB.sql | 82 ++-- velconnect/Dockerfile | 8 + velconnect/db.py | 81 ++-- velconnect/main.py | 14 +- velconnect/rebuild.sh | 4 + velconnect/{ => routes}/api.py | 112 ++---- velconnect/routes/api_v2.py | 208 +++++++++++ velconnect/routes/oculus_api.py | 52 +++ velconnect/routes/user_count.py | 68 ++++ velconnect/{ => routes}/website.py | 2 +- velconnect/templates/index.html | 2 +- 21 files changed, 899 insertions(+), 178 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 unity_package/.gitignore create mode 100644 unity_package/Editor.meta create mode 100644 unity_package/Runtime.meta create mode 100644 unity_package/Runtime/VEL-Connect.asmdef create mode 100644 unity_package/Runtime/VEL-Connect.asmdef.meta create mode 100644 unity_package/Runtime/VELConnectManager.cs create mode 100644 unity_package/Runtime/VELConnectManager.cs.meta create mode 100644 unity_package/package.json create mode 100644 unity_package/package.json.meta create mode 100644 velconnect/Dockerfile create mode 100644 velconnect/rebuild.sh rename velconnect/{ => routes}/api.py (59%) create mode 100644 velconnect/routes/api_v2.py create mode 100644 velconnect/routes/oculus_api.py create mode 100644 velconnect/routes/user_count.py rename velconnect/{ => routes}/website.py (94%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b1387c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +on: + push: + branches: + - main +jobs: + split-upm: + name: split upm branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: split upm branch + run: | + git subtree split -P "$PKG_ROOT" -b upm + git push -u origin upm + env: + PKG_ROOT: unity_package \ No newline at end of file diff --git a/unity_package/.gitignore b/unity_package/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/unity_package/Editor.meta b/unity_package/Editor.meta new file mode 100644 index 0000000..3b838bb --- /dev/null +++ b/unity_package/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0ce56caac2a2b78479e2bebd4f84101f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity_package/Runtime.meta b/unity_package/Runtime.meta new file mode 100644 index 0000000..0ea686e --- /dev/null +++ b/unity_package/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5b08ac62f2f2a9642bc42da52d43f5de +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity_package/Runtime/VEL-Connect.asmdef b/unity_package/Runtime/VEL-Connect.asmdef new file mode 100644 index 0000000..827a4a0 --- /dev/null +++ b/unity_package/Runtime/VEL-Connect.asmdef @@ -0,0 +1,18 @@ +{ + "name": "VEL-Connect", + "rootNamespace": "VEL-Connect", + "references": [ + "GUID:1e55e2c4387020247a1ae212bbcbd381", + "GUID:343deaaf83e0cee4ca978e7df0b80d21", + "GUID:2bafac87e7f4b9b418d9448d219b01ab" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} \ No newline at end of file diff --git a/unity_package/Runtime/VEL-Connect.asmdef.meta b/unity_package/Runtime/VEL-Connect.asmdef.meta new file mode 100644 index 0000000..d166b25 --- /dev/null +++ b/unity_package/Runtime/VEL-Connect.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 907e38b516b504b408923c7bed1662f6 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity_package/Runtime/VELConnectManager.cs b/unity_package/Runtime/VELConnectManager.cs new file mode 100644 index 0000000..a34ff96 --- /dev/null +++ b/unity_package/Runtime/VELConnectManager.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using Newtonsoft.Json; +using UnityEngine; +using UnityEngine.Networking; +using VelNet; + +namespace VELConnect +{ + public class VELConnectManager : MonoBehaviour + { + public string velConnectUrl = "http://localhost"; + public static VELConnectManager instance; + + public class State + { + public class Device + { + public string hw_id; + public string os_info; + public string friendly_name; + public string modified_by; + public string current_app; + public string current_room; + public int pairing_code; + public string date_created; + public string last_modified; + public Dictionary data; + + /// + /// Returns the value if it exists, otherwise null + /// + public string TryGetData(string key) + { + return data?.TryGetValue(key, out string val) == true ? val : null; + } + } + + public class RoomState + { + public string error; + public string id; + public string category; + public string date_created; + public string modified_by; + public string last_modified; + public string last_accessed; + public Dictionary data; + } + + public Device device; + public RoomState room; + } + + public State lastState; + + public static Action OnInitialState; + public static Action OnDeviceFieldChanged; + public static Action OnDeviceDataChanged; + public static Action OnRoomDataChanged; + + public static int PairingCode + { + get + { + Hash128 hash = new Hash128(); + hash.Append(DeviceId); + // change once a day + hash.Append(DateTime.UtcNow.DayOfYear); + // between 1000 and 9999 inclusive (any 4 digit number) + return Math.Abs(hash.GetHashCode()) % 9000 + 1000; + } + } + + private static string DeviceId + { + get + { +#if UNITY_STANDALONE_WIN && !UNITY_EDITOR + // allows running multiple builds on the same computer + // return SystemInfo.deviceUniqueIdentifier + Hash128.Compute(Application.dataPath); + return SystemInfo.deviceUniqueIdentifier + "_BUILD"; +#else + return SystemInfo.deviceUniqueIdentifier; +#endif + } + } + + private void Awake() + { + if (instance != null) Debug.LogError("VELConnectManager instance already exists", this); + instance = this; + } + + // Start is called before the first frame update + private void Start() + { + SetDeviceBaseData(new Dictionary + { + { "current_app", Application.productName }, + { "pairing_code", PairingCode } + }); + + UpdateUserCount(); + + + StartCoroutine(SlowLoop()); + + VelNetManager.OnJoinedRoom += room => + { + SetDeviceBaseData(new Dictionary + { + { "current_app", Application.productName }, + { "current_room", room }, + }); + }; + } + + + private void UpdateUserCount(bool leaving = false) + { + if (!VelNetManager.InRoom) return; + + VelNetManager.GetRooms(rooms => + { + Dictionary postData = new Dictionary + { + { "hw_id", DeviceId }, + { "app_id", Application.productName }, + { "room_id", VelNetManager.Room ?? "" }, + { "total_users", rooms.rooms.Sum(r => r.numUsers) - (leaving ? 1 : 0) }, + { "room_users", VelNetManager.PlayerCount - (leaving ? 1 : 0) }, + { "version", Application.version }, + { "platform", SystemInfo.operatingSystem }, + }; + PostRequestCallback(velConnectUrl + "/api/v2/update_user_count", JsonConvert.SerializeObject(postData)); + }); + } + + private IEnumerator SlowLoop() + { + while (true) + { + try + { + GetRequestCallback(velConnectUrl + "/api/v2/get_state/" + DeviceId, json => + { + State state = JsonConvert.DeserializeObject(json); + if (state == null) return; + + // first load stuff + if (lastState == null) + { + try + { + OnInitialState?.Invoke(state); + } + catch (Exception e) + { + Debug.LogError(e); + } + + lastState = state; + return; + } + + + if (state.device.modified_by != DeviceId) + { + FieldInfo[] fields = state.device.GetType().GetFields(); + + foreach (FieldInfo fieldInfo in fields) + { + string newValue = fieldInfo.GetValue(state.device) as string; + string oldValue = fieldInfo.GetValue(lastState.device) as string; + if (newValue != oldValue) + { + try + { + OnDeviceFieldChanged?.Invoke(fieldInfo.Name, newValue); + } + catch (Exception e) + { + Debug.LogError(e); + } + } + } + + foreach (KeyValuePair elem in state.device.data) + { + lastState.device.data.TryGetValue(elem.Key, out string oldValue); + if (elem.Value != oldValue) + { + try + { + OnDeviceDataChanged?.Invoke(elem.Key, elem.Value); + } + catch (Exception e) + { + Debug.LogError(e); + } + } + } + } + + if (state.room.modified_by != DeviceId) + { + foreach (KeyValuePair elem in state.room.data) + { + lastState.room.data.TryGetValue(elem.Key, out string oldValue); + if (elem.Value != oldValue) + { + try + { + OnRoomDataChanged?.Invoke(elem.Key, elem.Value); + } + catch (Exception e) + { + Debug.LogError(e); + } + } + } + } + + lastState = state; + }); + } + catch (Exception e) + { + Debug.LogError(e); + // this make sure the coroutine never quits + } + + yield return new WaitForSeconds(1); + } + } + + + /// + /// Sets data on the device keys themselves + /// + public static void SetDeviceBaseData(Dictionary data) + { + data["modified_by"] = DeviceId; + instance.PostRequestCallback( + instance.velConnectUrl + "/api/v2/device/set_data/" + DeviceId, + JsonConvert.SerializeObject(data) + ); + } + + /// + /// Sets the 'data' object of the Device table + /// + public static void SetDeviceData(Dictionary data) + { + instance.PostRequestCallback( + instance.velConnectUrl + "/api/v2/device/set_data/" + DeviceId, + JsonConvert.SerializeObject(new Dictionary + { + { "modified_by", DeviceId }, + { "data", data } + }) + ); + } + + public static void SetRoomData(Dictionary data) + { + if (!VelNetManager.InRoom) + { + Debug.LogError("Can't set data for a room if you're not in a room."); + return; + } + + data["modified_by"] = DeviceId; + instance.PostRequestCallback( + instance.velConnectUrl + "/api/v2/set_data/" + Application.productName + "_" + VelNetManager.Room, + JsonConvert.SerializeObject(data) + ); + } + + + public void GetRequestCallback(string url, Action successCallback = null, Action failureCallback = null) + { + StartCoroutine(GetRequestCallbackCo(url, successCallback, failureCallback)); + } + + private IEnumerator GetRequestCallbackCo(string url, Action successCallback = null, Action failureCallback = null) + { + using UnityWebRequest webRequest = UnityWebRequest.Get(url); + // Request and wait for the desired page. + yield return webRequest.SendWebRequest(); + + switch (webRequest.result) + { + case UnityWebRequest.Result.ConnectionError: + case UnityWebRequest.Result.DataProcessingError: + case UnityWebRequest.Result.ProtocolError: + Debug.LogError(url + ": Error: " + webRequest.error); + failureCallback?.Invoke(webRequest.error); + break; + case UnityWebRequest.Result.Success: + successCallback?.Invoke(webRequest.downloadHandler.text); + break; + } + } + + public void PostRequestCallback(string url, string postData, Action successCallback = null, + Action failureCallback = null) + { + StartCoroutine(PostRequestCallbackCo(url, postData, successCallback, failureCallback)); + } + + private static IEnumerator PostRequestCallbackCo(string url, string postData, Action successCallback = null, + Action failureCallback = null) + { + UnityWebRequest webRequest = new UnityWebRequest(url, "POST"); + byte[] bodyRaw = Encoding.UTF8.GetBytes(postData); + UploadHandlerRaw uploadHandler = new UploadHandlerRaw(bodyRaw); + webRequest.uploadHandler = uploadHandler; + webRequest.SetRequestHeader("Content-Type", "application/json"); + yield return webRequest.SendWebRequest(); + + switch (webRequest.result) + { + case UnityWebRequest.Result.ConnectionError: + case UnityWebRequest.Result.DataProcessingError: + case UnityWebRequest.Result.ProtocolError: + Debug.LogError(url + ": Error: " + webRequest.error); + failureCallback?.Invoke(webRequest.error); + break; + case UnityWebRequest.Result.Success: + successCallback?.Invoke(webRequest.downloadHandler.text); + break; + } + + uploadHandler.Dispose(); + webRequest.Dispose(); + } + + private void OnApplicationFocus(bool focus) + { + UpdateUserCount(!focus); + } + } +} \ No newline at end of file diff --git a/unity_package/Runtime/VELConnectManager.cs.meta b/unity_package/Runtime/VELConnectManager.cs.meta new file mode 100644 index 0000000..5ccff37 --- /dev/null +++ b/unity_package/Runtime/VELConnectManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a55631aea982e21409a42ae6a1bd5814 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity_package/package.json b/unity_package/package.json new file mode 100644 index 0000000..2ac6467 --- /dev/null +++ b/unity_package/package.json @@ -0,0 +1,17 @@ +{ + "name": "edu.uga.engr.vel.vel-connect", + "displayName": "VEL-Connect", + "version": "1.0.0", + "unity": "2019.1", + "description": "Web-based configuration for VR applications", + "keywords": [], + "author": { + "name": "Virtual Experiences Laboratory", + "email": "velaboratory@gmail.com", + "url": "https://vel.engr.uga.edu/" + }, + "samples": [], + "dependencies": { + "com.unity.nuget.newtonsoft-json": "3.0.0" + } +} \ No newline at end of file diff --git a/unity_package/package.json.meta b/unity_package/package.json.meta new file mode 100644 index 0000000..6e336e6 --- /dev/null +++ b/unity_package/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3e4b6bbeec5c39a428ab34a5e59d56ba +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/velconnect/CreateDB.sql b/velconnect/CreateDB.sql index 716008c..b509a19 100644 --- a/velconnect/CreateDB.sql +++ b/velconnect/CreateDB.sql @@ -1,41 +1,3 @@ -DROP TABLE IF EXISTS `Room`; -CREATE TABLE `Room` ( - `room_id` VARCHAR(64) NOT NULL PRIMARY KEY, - `date_created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `last_modified` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- Can be null if no owner - `owner` VARCHAR(64), - -- The last source to change this object - `modified_by` VARCHAR(64), - -- array of hw_ids of users allowed. Always includes the owner. Null for public - `whitelist` JSON, - `tv_url` VARCHAR(1024), - `carpet_color` VARCHAR(9), - `room_details` JSON -); -DROP TABLE IF EXISTS `Headset`; -CREATE TABLE `Headset` ( - `hw_id` VARCHAR(64) NOT NULL PRIMARY KEY, - -- The last source to change this object - `modified_by` VARCHAR(64), - -- The room_id of the owned room - `owned_room` VARCHAR(64), - -- The room_id of the current room. Can be null if room not specified - `current_room` VARCHAR(64) DEFAULT "0", - -- changes relatively often. Generated by the headset - `pairing_code` INT, - `date_created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- the last time this headset was actually seen - `last_used` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - `user_color` VARCHAR(9), - `user_name` VARCHAR(64), - `avatar_url` VARCHAR(128), - -- Stuff like player color, nickname, whiteboard state - `user_details` JSON, - `streamer_stream_id` VARCHAR(64), - `streamer_control_id` VARCHAR(64) -); -DROP TABLE IF EXISTS `APIKey`; CREATE TABLE `APIKey` ( `key` VARCHAR(64) NOT NULL PRIMARY KEY, -- 0 is all access, higher is less @@ -43,16 +5,54 @@ CREATE TABLE `APIKey` ( `auth_level` INT, `date_created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `last_used` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- Incremented every time this key is used `uses` INT DEFAULT 0 ); -DROP TABLE IF EXISTS `UserCount`; CREATE TABLE `UserCount` ( `timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `hw_id` VARCHAR(64) NOT NULL, + `app_id` VARCHAR(64) NOT NULL, `room_id` VARCHAR(64) NOT NULL, `total_users` INT NOT NULL DEFAULT 0, `room_users` INT NOT NULL DEFAULT 0, - `version` VARCHAR(32) DEFAULT "0", - `platform` VARCHAR(64) DEFAULT "none", + `version` VARCHAR(32), + `platform` VARCHAR(64), PRIMARY KEY (`timestamp`, `hw_id`) +); +CREATE TABLE `Device` ( + -- Unique identifier for this device + `hw_id` TEXT NOT NULL PRIMARY KEY, + -- info about the hardware. Would specify Quest or Windows for example + `os_info` TEXT, + -- A human-readable name for this device. Could be a username + `friendly_name` TEXT, + -- The last source to change this object. Generally this is the device id + `modified_by` TEXT, + -- The app_id of the current app. Can be null if app left cleanly + `current_app` TEXT, + -- The room_id of the current room. Can be null if room not specified. Could be some other sub-app identifier + `current_room` TEXT, + -- changes relatively often. Generated by the headset + `pairing_code` INT, + -- the first time this device was seen + `date_created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- the last time this device data was modified + `last_modified` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- JSON containing arbitrary data + `data` TEXT +); + +CREATE TABLE `DataBlock` ( + -- Could be randomly generated. For room data, this is 'appId_roomName' + `id` TEXT NOT NULL PRIMARY KEY, + -- This is an indexable field to filter out different types of datablocks + `category` TEXT, + `date_created` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- The last source to change this object. Generally this is the device id + `modified_by` TEXT, + `last_modified` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- the last time this data was fetched individually + `last_accessed` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- JSON containing arbitrary data + `data` TEXT ); \ No newline at end of file diff --git a/velconnect/Dockerfile b/velconnect/Dockerfile new file mode 100644 index 0000000..c190a86 --- /dev/null +++ b/velconnect/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.10 +WORKDIR /usr/src/velconnect +COPY ./requirements.txt /usr/src/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /usr/src/requirements.txt +COPY . /usr/src/velconnect +EXPOSE 80 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80"] + diff --git a/velconnect/db.py b/velconnect/db.py index 92df736..8f5927f 100644 --- a/velconnect/db.py +++ b/velconnect/db.py @@ -3,48 +3,49 @@ import os import traceback -def create_or_connect(): - db_name = 'velconnect.db' - create = False - if not os.path.exists(db_name, ): - create = True +class DB: + def __init__(self, db_name): + self.db_name = db_name - conn = sqlite3.connect(db_name) - conn.row_factory = sqlite3.Row - curr = conn.cursor() - if create: - # create the db - with open('CreateDB.sql', 'r') as f: - curr.executescript(f.read()) + def create_or_connect(self): + create = False + if not os.path.exists(self.db_name, ): + create = True - conn.set_trace_callback(print) - return conn, curr + conn = sqlite3.connect(self.db_name) + conn.row_factory = sqlite3.Row + curr = conn.cursor() + if create: + # create the db + with open('CreateDB.sql', 'r') as f: + curr.executescript(f.read()) + conn.set_trace_callback(print) + return conn, curr -def query(query: str, data: dict = None) -> list: - try: - conn, curr = create_or_connect() - if data is not None: - curr.execute(query, data) - else: - curr.execute(query) - values = curr.fetchall() - conn.close() - return values - except: - print(traceback.print_exc()) - conn.close() - raise + def query(self, query_string: str, data: dict = None) -> list: + try: + conn, curr = self.create_or_connect() + if data is not None: + curr.execute(query_string, data) + else: + curr.execute(query_string) + values = curr.fetchall() + conn.close() + return values + except: + print(traceback.print_exc()) + conn.close() + raise - -def insert(query: str, data: dict = None) -> bool: - try: - conn, curr = create_or_connect() - curr.execute(query, data) - conn.commit() - conn.close() - return True - except: - print(traceback.print_exc()) - conn.close() - raise + def insert(self, query_string: str, data: dict = None) -> bool: + try: + conn, curr = self.create_or_connect() + curr.execute(query_string, data) + conn.commit() + conn.close() + return True + except: + print(traceback.print_exc()) + conn.close() + raise diff --git a/velconnect/main.py b/velconnect/main.py index 35256bb..f7769f1 100644 --- a/velconnect/main.py +++ b/velconnect/main.py @@ -1,11 +1,14 @@ -from imp import reload import uvicorn -from fastapi.middleware.cors import CORSMiddleware from fastapi import FastAPI -from api import router as api_router -from website import router as website_router +from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from routes.api import router as api_router +from routes.api_v2 import router as api_v2_router +from routes.user_count import router as user_count_router +from routes.oculus_api import router as oculus_api_router +from routes.website import router as website_router + app = FastAPI() origins = [ @@ -26,6 +29,9 @@ app.add_middleware( app.mount("/static", StaticFiles(directory="static"), name="static") app.include_router(api_router) +app.include_router(api_v2_router) +app.include_router(user_count_router) +app.include_router(oculus_api_router) app.include_router(website_router) if __name__ == '__main__': diff --git a/velconnect/rebuild.sh b/velconnect/rebuild.sh new file mode 100644 index 0000000..27d157e --- /dev/null +++ b/velconnect/rebuild.sh @@ -0,0 +1,4 @@ +docker build -t velconnect . +docker rm web +docker run -p 8081:80 --name web velconnect + diff --git a/velconnect/api.py b/velconnect/routes/api.py similarity index 59% rename from velconnect/api.py rename to velconnect/routes/api.py index 6289103..ed7bdea 100644 --- a/velconnect/api.py +++ b/velconnect/routes/api.py @@ -1,16 +1,12 @@ from fastapi import APIRouter -from fastapi import Query -from typing import Optional -from fastapi.responses import HTMLResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from fastapi import FastAPI, Body, Depends, HTTPException, status +from fastapi import Depends, HTTPException, status +from fastapi.responses import HTMLResponse from fastapi.security import OAuth2PasswordBearer -from db import query, insert from pydantic import BaseModel -from typing import Union -from pyppeteer import launch -from enum import Enum +import db + +db = db.DB("velconnect.db") # APIRouter creates path operations for user module router = APIRouter( @@ -25,7 +21,7 @@ oauth2_scheme = OAuth2PasswordBearer( def api_key_auth(api_key: str = Depends(oauth2_scheme)): return True - values = query( + values = db.query( "SELECT * FROM `APIKey` WHERE `key`=:key;", {'key': api_key}) if not (len(values) > 0 and values['auth_level'] < 0): raise HTTPException( @@ -65,17 +61,16 @@ async def read_root(): @router.get('/get_all_headsets') def get_all_headsets(): """Returns a list of all headsets and details associated with them.""" - values = query("SELECT * FROM `Headset`;") + values = db.query("SELECT * FROM `Headset`;") return values @router.get('/pair_headset/{pairing_code}') def pair_headset(pairing_code: str): - values = query("SELECT * FROM `Headset` WHERE `pairing_code`=:pairing_code;", + values = db.query("SELECT * FROM `Headset` WHERE `pairing_code`=:pairing_code;", {'pairing_code': pairing_code}) if len(values) == 1: - print(values[0]['hw_id']) - return {'hw_id': values[0]['hw_id']} + return values[0] return {'error': 'Not found'}, 400 @@ -93,7 +88,7 @@ def update_pairing_code(data: UpdatePairingCode): create_headset(data.hw_id) - insert(""" + db.insert(""" UPDATE `Headset` SET `pairing_code`=:pairing_code, `last_used`=CURRENT_TIMESTAMP WHERE `hw_id`=:hw_id; @@ -103,8 +98,8 @@ def update_pairing_code(data: UpdatePairingCode): def create_headset(hw_id: str): - insert(""" - INSERT OR IGNORE INTO Headset(hw_id) VALUES (:hw_id); + db.insert(""" + db.insert IGNORE INTO Headset(hw_id) VALUES (:hw_id); """, {'hw_id': hw_id}) @@ -118,7 +113,7 @@ def get_headset_details(hw_id: str): def get_headset_details_db(hw_id): - headsets = query(""" + headsets = db.query(""" SELECT * FROM `Headset` WHERE `hw_id`=:hw_id; """, {'hw_id': hw_id}) if len(headsets) == 0: @@ -150,8 +145,8 @@ def set_headset_details_generic(hw_id: str, data: dict): if key in allowed_keys: if key == 'current_room': create_room(data['current_room']) - insert(f"UPDATE `Headset` SET {key}=:value, modified_by=:sender_id WHERE `hw_id`=:hw_id;", { - 'value': data[key], 'hw_id': hw_id, 'sender_id': data['sender_id']}) + db.insert(f"UPDATE `Headset` SET {key}=:value, modified_by=:sender_id WHERE `hw_id`=:hw_id;", { + 'value': data[key], 'hw_id': hw_id, 'sender_id': data['sender_id']}) return {'success': True} @@ -168,8 +163,9 @@ def set_room_details_generic(room_id: str, data: dict): for key in data: if key in allowed_keys: - insert("UPDATE `Room` SET " + key + - "=:value, modified_by=:sender_id WHERE `room_id`=:room_id;", {'value': data[key], 'room_id': room_id, 'sender_id': data['sender_id']}) + db.insert("UPDATE `Room` SET " + key + + "=:value, modified_by=:sender_id WHERE `room_id`=:room_id;", + {'value': data[key], 'room_id': room_id, 'sender_id': data['sender_id']}) return {'success': True} @@ -179,7 +175,7 @@ def get_room_details(room_id: str): def get_room_details_db(room_id): - values = query(""" + values = db.query(""" SELECT * FROM `Room` WHERE room_id=:room_id; """, {'room_id': room_id}) if len(values) == 1: @@ -189,77 +185,11 @@ def get_room_details_db(room_id): def create_room(room_id): - insert(""" - INSERT OR IGNORE INTO `Room`(room_id) + db.insert(""" + db.insert IGNORE INTO `Room`(room_id) VALUES( :room_id ); """, {'room_id': room_id}) return {'room_id': room_id} - -@router.post('/update_user_count', tags=["User Count"]) -def update_user_count(data: dict): - insert(""" - REPLACE INTO `UserCount` - VALUES( - CURRENT_TIMESTAMP, - :hw_id, - :room_id, - :total_users, - :room_users, - :version, - :platform - ); - """, data) - return {'success': True} - - -@router.get('/get_user_count', tags=["User Count"]) -def get_user_count(hours: float = 24): - values = query(""" - SELECT timestamp, total_users - FROM `UserCount` - WHERE TIMESTAMP > DATE_SUB(NOW(), INTERVAL """ + str(hours) + """ HOUR); - """) - return values - - -class QuestRift(str, Enum): - quest = "quest" - rift = "rift" - - -@router.get('/get_store_details/{quest_rift}/{app_id}', tags=["Oculus API"]) -async def get_version_nums(quest_rift: QuestRift, app_id: int): - browser = await launch(headless=True, options={'args': ['--no-sandbox']}) - page = await browser.newPage() - await page.goto(f'https://www.oculus.com/experiences/{quest_rift}/{app_id}') - - ret = {} - - # title - title = await page.querySelector(".app-description__title") - ret["title"] = await page.evaluate("e => e.textContent", title) - - # description - desc = await page.querySelector(".clamped-description__content") - ret["description"] = await page.evaluate("e => e.textContent", desc) - - # versions - await page.evaluate("document.querySelector('.app-details-version-info-row__version').nextElementSibling.firstChild.click();") - elements = await page.querySelectorAll('.sky-dropdown__link.link.link--clickable') - - versions = [] - for e in elements: - v = await page.evaluate('(element) => element.textContent', e) - versions.append({ - 'channel': v.split(':')[0], - 'version': v.split(':')[1] - }) - - ret["versions"] = versions - - await browser.close() - - return ret diff --git a/velconnect/routes/api_v2.py b/velconnect/routes/api_v2.py new file mode 100644 index 0000000..790d804 --- /dev/null +++ b/velconnect/routes/api_v2.py @@ -0,0 +1,208 @@ +import secrets +import json +import string + +import fastapi +from fastapi.responses import HTMLResponse +from pydantic import BaseModel + +import db + +db = db.DB("velconnect_v2.db") + +# APIRouter creates path operations for user module +router = fastapi.APIRouter( + prefix="/api/v2", + tags=["API V2"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/", response_class=HTMLResponse, include_in_schema=False) +async def read_root(): + return """ + + + + + + API Reference + + + +
+ +
+
+ + +""" + + +@router.get('/get_all_devices') +def get_all_devices(): + """Returns a list of all devices and details associated with them.""" + values = db.query("SELECT * FROM `Device`;") + return values + + +@router.get('/get_device_by_pairing_code/{pairing_code}') +def get_device_by_pairing_code(pairing_code: str): + values = db.query("SELECT * FROM `Device` WHERE `pairing_code`=:pairing_code;", + {'pairing_code': pairing_code}) + if len(values) == 1: + return values[0] + return {'error': 'Not found'}, 400 + + +def create_device(hw_id: str): + db.insert(""" + INSERT IGNORE INTO `Device`(hw_id) VALUES (:hw_id); + """, {'hw_id': hw_id}) + + +@router.get('/device/get_data/{hw_id}') +def get_state(hw_id: str): + """Gets the device state""" + + devices = db.query(""" + SELECT * FROM `Device` WHERE `hw_id`=:hw_id; + """, {'hw_id': hw_id}) + if len(devices) == 0: + return {'error': "Can't find device with that id."} + + room_data = get_data(f"{devices[0]['current_app']}_{devices[0]['current_room']}") + + return {'device': devices[0], 'room': room_data} + + +@router.post('/device/set_data/{hw_id}') +def set_state(hw_id: str, data: dict, request: fastapi.Request): + """Sets the device state""" + + create_device(hw_id) + + # add the client's IP address if no sender specified + if 'modified_by' not in data: + data['modified_by'] = str(request.client) + "_" + str(request.headers) + + allowed_keys: list[str] = [ + 'os_info', + 'friendly_name', + 'modified_by', + 'current_app', + 'current_room', + 'pairing_code', + ] + + for key in data: + if key in allowed_keys: + db.insert(f""" + UPDATE `Device` + SET {key}=:value, + last_modified=CURRENT_TIMESTAMP + WHERE `hw_id`=:hw_id; + """, + { + 'value': data[key], + 'hw_id': hw_id, + 'sender_id': data['sender_id'] + }) + if key == "data": + # get the old json values and merge the data + old_data_query = db.query(""" + SELECT data + FROM `Device` + WHERE hw_id=:hw_id + """, {"hw_id": hw_id}) + + if len(old_data_query) == 1: + old_data: dict = json.loads(old_data_query[0]["data"]) + data = {**old_data, **data} + + # add the data to the db + db.insert(""" + UPDATE `Device` + SET data=:data, + last_modified=CURRENT_TIMESTAMP + WHERE hw_id=:hw_id; + """, {"hw_id": hw_id, "data": json.dumps(data)}) + return {'success': True} + + +def generate_id(length: int = 4) -> str: + return ''.join( + secrets.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for i in range(length)) + + +@router.post('/set_data') +def store_data_with_random_key(request: fastapi.Request, data: dict, category: str = None) -> dict: + """Creates a little storage bucket for arbitrary data with a random key""" + return store_data(request, data, None, category) + + +@router.post('/set_data/{key}') +def store_data(request: fastapi.Request, data: dict, key: str = None, modified_by: str = None, category: str = None) -> dict: + """Creates a little storage bucket for arbitrary data""" + + # add the client's IP address if no sender specified + if modified_by is None: + modified_by = str(request.client) + "_" + str(request.headers) + + # generates a key if none was supplied + if key is None: + key = generate_id() + + # regenerate if necessary + while len(db.query("SELECT id FROM `DataBlock` WHERE id=:id;", {"id": key})) > 0: + key = generate_id() + + # get the old json values and merge the data + old_data_query = db.query(""" + SELECT data + FROM `DataBlock` + WHERE id=:id + """, {"id": key}) + + if len(old_data_query) == 1: + old_data: dict = json.loads(old_data_query[0]["data"]) + data = {**old_data, **data} + + # add the data to the db + db.insert(""" + REPLACE INTO `DataBlock` (id, category, modified_by, data, last_modified) + VALUES(:id, :category, :modified_by, :data, CURRENT_TIMESTAMP); + """, {"id": key, "category": category, "modified_by": modified_by, "data": json.dumps(data)}) + + return {'key': key} + + +@router.get('/get_data/{key}') +def get_data(key: str) -> dict: + """Gets data from a storage bucket for arbitrary data""" + + data = db.query(""" + SELECT data + FROM `DataBlock` + WHERE id=:id + """, {"id": key}) + + db.insert(""" + UPDATE `DataBlock` + SET last_accessed = CURRENT_TIMESTAMP + WHERE id=:id; + """, {"id": key}) + + try: + if len(data) == 1: + return json.loads(data[0]) + return {'error': 'Not found'} + except: + return {'error': 'Unknown. Maybe no data at this key.'} diff --git a/velconnect/routes/oculus_api.py b/velconnect/routes/oculus_api.py new file mode 100644 index 0000000..ec3851b --- /dev/null +++ b/velconnect/routes/oculus_api.py @@ -0,0 +1,52 @@ +from enum import Enum + +import fastapi +from pyppeteer import launch + +# APIRouter creates path operations for user module +router = fastapi.APIRouter( + prefix="/api", + tags=["Oculus API"], + responses={404: {"description": "Not found"}}, +) + + +class QuestRift(str, Enum): + quest = "quest" + rift = "rift" + + +@router.get('/get_store_details/{quest_rift}/{app_id}') +async def get_version_nums(quest_rift: QuestRift, app_id: int): + browser = await launch(headless=True, options={'args': ['--no-sandbox']}) + page = await browser.newPage() + await page.goto(f'https://www.oculus.com/experiences/{quest_rift}/{app_id}') + + ret = {} + + # title + title = await page.querySelector(".app-description__title") + ret["title"] = await page.evaluate("e => e.textContent", title) + + # description + desc = await page.querySelector(".clamped-description__content") + ret["description"] = await page.evaluate("e => e.textContent", desc) + + # versions + await page.evaluate( + "document.querySelector('.app-details-version-info-row__version').nextElementSibling.firstChild.click();") + elements = await page.querySelectorAll('.sky-dropdown__link.link.link--clickable') + + versions = [] + for e in elements: + v = await page.evaluate('(element) => element.textContent', e) + versions.append({ + 'channel': v.split(':')[0], + 'version': v.split(':')[1] + }) + + ret["versions"] = versions + + await browser.close() + + return ret diff --git a/velconnect/routes/user_count.py b/velconnect/routes/user_count.py new file mode 100644 index 0000000..f51d0d9 --- /dev/null +++ b/velconnect/routes/user_count.py @@ -0,0 +1,68 @@ +import fastapi + +import db + +db = db.DB("velconnect.db") + +# APIRouter creates path operations for user module +router = fastapi.APIRouter( + prefix="/api", + tags=["User Count"], + responses={404: {"description": "Not found"}}, +) + +post_user_count_example = { + "default": { + "summary": "Example insert for user count", + "value": { + "hw_id": "1234", + "app_id": "example", + "room_id": "0", + "total_users": 1, + "room_users": 1, + "version": "0.1", + "platform": "Windows" + } + } +} + + +@router.post('/update_user_count') +def update_user_count(data: dict = fastapi.Body(..., examples=post_user_count_example)) -> dict: + if 'app_id' not in data: + data['app_id'] = "" + + db.insert(""" + REPLACE INTO `UserCount` ( + timestamp, + hw_id, + app_id, + room_id, + total_users, + room_users, + version, + platform + ) + VALUES( + CURRENT_TIMESTAMP, + :hw_id, + :app_id, + :room_id, + :total_users, + :room_users, + :version, + :platform + ); + """, data) + return {'success': True} + + +@router.get('/get_user_count') +def get_user_count(app_id: str = None, hours: float = 24) -> list: + values = db.query(""" + SELECT timestamp, total_users + FROM `UserCount` + WHERE app_id = :app_id AND + timestamp > datetime('now', '-""" + str(hours) + """ Hour'); + """, {"app_id": app_id}) + return values diff --git a/velconnect/website.py b/velconnect/routes/website.py similarity index 94% rename from velconnect/website.py rename to velconnect/routes/website.py index ebbbd96..fa41f47 100644 --- a/velconnect/website.py +++ b/velconnect/routes/website.py @@ -1,11 +1,11 @@ from fastapi import APIRouter from fastapi.responses import FileResponse - # APIRouter creates path operations for user module router = APIRouter( prefix="", tags=["Website"], + include_in_schema=False ) diff --git a/velconnect/templates/index.html b/velconnect/templates/index.html index 6342f05..a6adf72 100644 --- a/velconnect/templates/index.html +++ b/velconnect/templates/index.html @@ -186,7 +186,7 @@