diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/img/icon.png | bin | 0 -> 21396 bytes | |||
-rw-r--r-- | src/index.html | 61 | ||||
-rw-r--r-- | src/js/hoverControlModule.js | 75 | ||||
-rw-r--r-- | src/js/main.js | 158 | ||||
-rw-r--r-- | src/js/notification.js | 83 | ||||
-rw-r--r-- | src/manifest.webmanifest | 16 | ||||
-rw-r--r-- | src/scss/styles.scss | 280 | ||||
-rw-r--r-- | src/service-worker.js | 61 |
8 files changed, 734 insertions, 0 deletions
diff --git a/src/img/icon.png b/src/img/icon.png Binary files differnew file mode 100644 index 0000000..be75d96 --- /dev/null +++ b/src/img/icon.png diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..fb23d7e --- /dev/null +++ b/src/index.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html lang="en" dir="ltr"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="viewport-fit=cover,width=device-width, initial-scale=1, shrink-to-fit=no,initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <meta name="HandheldFriendly" content="true" /> + + <title>HOVER:BIT Controller</title> + <link rel="canonical" rel="https://jakobst1n.github.io/hoverbit-ble/"> + + <link rel="stylesheet" type="text/css" href="./scss/styles.scss"> + <link rel="manifest" href="manifest.webmanifest" crossorigin="use-credentials"> + + <link rel="preconnect" href="https://fonts.gstatic.com"> + <link href="https://fonts.googleapis.com/css2?family=Kufam&display=swap" rel="stylesheet"> + </head> + <body class=""> + <!-- <h1>HOVER:BIT Controller</h1> --> + + <div class="landscape-warning"> + <div class="landscape-warning-content"> + <i class="fas fa-sync-alt"></i> + <h1>Please use landscape mode</h1> + <button id="btn_ignore_landscape_warning">Ignore</button> + </div> + </div> + + <div class="top-buttons"> + </div> + + <button class="button-center button-center-top" id="btn_connect">CONNECT</button> + <button class="button-center button-center-top" id="btn_disconnect">DISCONNECT</button> + + <div class="acc-string"><pre>T: 0, R: 0</pre></div> + + <button class="button-center button-center-bottom" id="btn_arm">ARM</button> + <button class="button-center button-center-bottom" id="btn_disarm">DISARM</button> + + <div class="joystick joystick-left"></div> + <div class="joystick joystick-right"></div> + + <div class="statusline"> + <span class="statusline-item connection"> + <i class="fab fa-bluetooth-b"></i> + </span> + <span class="statusline-item arm"> + <i class="fas fa-exclamation-triangle"></i> + </span> + <span class="notification-area"></span> + <span class="statusline-item ping"> + <i class="fas fa-broadcast-tower"></i> + </span> + <span class="statusline-item battery"> + <i class="fas fa-car-battery"></i> + <span class="battery-status">0%</span> + </span> + </div> + + <script type="text/javascript" src="./js/main.js"></script> + </body> +</html> diff --git a/src/js/hoverControlModule.js b/src/js/hoverControlModule.js new file mode 100644 index 0000000..44c06fb --- /dev/null +++ b/src/js/hoverControlModule.js @@ -0,0 +1,75 @@ +import { notif_alert, notif_warn, notif_info, notif_success } from './notification'; + +export default class hoverControlModule { + #throttle = 0; + #throttleAcc = 0; + #rudder = 0; + #rudderAcc = 0; + #arm = 0; + #armAcc = 0; + + constructor() {} + + acc(accString) { + accString.match(/[A-Z][-,0-9]+/g).forEach((item, i) => { + switch (item.substring(0, 1)) { + case "T": + this.#throttleAcc = parseInt(item.substring(1, item.length)); + break; + case "R": + this.#rudderAcc = parseInt(item.substring(1, item.length)); + break; + case "A": + this.#armAcc = parseInt(item.substring(1, item.length)) == 1; + if (this.#armAcc) { + document.body.classList.add("armed"); + } else { + document.body.classList.remove("armed"); + } + break; + case "S": + break; + default: + console.log(`Unkown acc: ${item}`); + } + }); + document.querySelector(".acc-string pre").innerHTML = `T: ${this.#throttleAcc}, R: ${this.#rudderAcc}`; + } + + reset() { + this.setArm(0); + this.setThrottle(0); + this.setRudder(0); + } + + setThrottle(throttle) { + if (!this.#armAcc) { return; } + if (throttle > 100) { throttle = 100; } + if (throttle < 0) { throttle = 0; } + this.#throttle = throttle; + } + getThrottle() { + return this.#throttle; + } + + setRudder(rudder) { + if (!this.#armAcc) { return; } + if (rudder > 90) { rudder = 90; } + if (rudder < -90) { rudder = -90; } + this.#rudder = rudder; + } + getRudder() { + return this.#rudder; + } + + setArm(arm) { + this.#arm = arm; + if (!this.#arm) { + this.#throttle = 0; + this.#rudder = 0; + } + } + getArm() { + return this.#arm; + } +} diff --git a/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..1c26af9 --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,158 @@ +import nipplejs from 'nipplejs'; +import { requestMicrobit, getServices } from 'microbit-web-bluetooth'; +import hoverControlModule from './hoverControlModule'; +import { notif_alert, notif_warn, notif_info, notif_success } from './notification'; + +let sw = "service-worker.js"; +//if (navigator.serviceWorker) { +// navigator.serviceWorker.register(sw, {scope: '/hoverbit-ble/'}); +//} +navigator.serviceWorker.register( + sw, {scope: '/hoverbit-ble/'} +).then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker == null) { return; } + installingWorker.onstatechange = () => { + if (installingWorker.state === "installed") { + if (navigator.serviceWorker.controller) { + notif_info("New content is available, relaunch the app to install it."); + } else { + notif_success("Content is cached for offline use."); + } + } + }; + }; +}).catch(error => { + notif_alert("Could not install service worker..."); + console.error("Error during service worker registration:", error); +}); + +document.getElementById("btn_ignore_landscape_warning").addEventListener("click", () => { + document.body.classList.add("ignore-landscape-warning"); +}); + +/* Define and initialize things */ +let hoverControl = new hoverControlModule(); +let bluetoothDevice; +let bluetoothDeviceServices; + +let joystickLeft = nipplejs.create({ + zone: document.querySelector(".joystick-left"), + size: 200, + position: {left: '50%', bottom: '50%'}, + mode: "static", + lockX: true +}); +let joystickRight = nipplejs.create({ + zone: document.querySelector(".joystick-right"), + size: 200, + position: {left: '50%', bottom: '50%'}, + mode: "static", + lockY: true +}); + +/* Setup event_listeners */ +joystickLeft.on("move", (evt, data) => { + let rudder = ((data.distance * 90) / 100); + if (data.angle.degree > 90) { rudder = rudder * -1; } + hoverControl.setRudder(rudder); +}); +joystickLeft.on("end", (evt, data) => { + hoverControl.setRudder(0); +}); + +joystickRight.on("move", (evt, data) => { + let throttle = data.distance; + if (data.angle.degree > 90) { throttle = 0; } + hoverControl.setThrottle(throttle); +}); +joystickRight.on("end", (evt, data) => { + hoverControl.setThrottle(0); +}); + +document.getElementById("btn_arm").addEventListener("click", () => { + hoverControl.setArm(true); +}); + +document.getElementById("btn_disarm").addEventListener("click", () => { + hoverControl.setArm(false); +}); + +document.querySelector("#btn_disconnect").addEventListener("click", () => { + hoverControl.reset(); + bluetoothDevice.gatt.disconnect(); +}); + +let intervalConnectionChecker = setInterval(() => { + if (bluetoothDevice !== undefined && bluetoothDevice) { + if (bluetoothDevice.gatt.connected) { + document.body.classList.add("connected"); + } else { + document.body.classList.remove("connected"); + document.body.classList.remove("armed"); + } + } else if (bluetoothDevice !== undefined) { + bluetoothDevice.gatt.reconnect(); + } +}, 500); + +let intervalSendCommands = setInterval(async() => { + if (bluetoothDevice !== undefined && bluetoothDevice) { + if (bluetoothDevice.gatt.connected && bluetoothDeviceServices.uartService) { + let command = + "T" + hoverControl.getThrottle().toString() + + "R" + hoverControl.getRudder().toString() + + "A" + (hoverControl.getArm() ? "1" : "0") + + "S0" + + ":"; + await bluetoothDeviceServices.uartService.sendText(command); + } + } +}, 70); + +function receiveText(event) { + /* Just make the ping symbol reappear. */ + var elm = document.querySelector(".ping i"); + var newone = elm.cloneNode(true); + elm.parentNode.replaceChild(newone, elm); + + /* Actually handle received text. */ + if ((event.detail).indexOf(":") != -1) { + let parts = (event.detail).split(":"); + + if (parts[0] == "B") { + document.querySelector(".battery-status").innerHTML = parts[1] + "mV"; + } else if (parts[0] == "ACC") { + hoverControl.acc(parts[1]); + } else { + console.log(parts); + } + } else { + notif_warn("Received weird data from MICRO:BIT..."); + console.log(`Received unknown: ${event.detail}`); + } +} + +document.getElementById("btn_connect").onclick = async () => { + if (bluetoothDevice !== undefined && bluetoothDevice.gatt.connected) { + bluetoothDevice.disconnect(); + } + + const device = await requestMicrobit(window.navigator.bluetooth); + bluetoothDevice = device; + + if (device) { + hoverControl.reset(); + const services = await getServices(device); + bluetoothDeviceServices = services; + + if (bluetoothDeviceServices.deviceInformationService) { + // logJson(await services.deviceInformationService.readDeviceInformation()); + } + + if (services.uartService) { + services.uartService.addEventListener("receiveText", receiveText); + } + } +} diff --git a/src/js/notification.js b/src/js/notification.js new file mode 100644 index 0000000..c2eb14c --- /dev/null +++ b/src/js/notification.js @@ -0,0 +1,83 @@ +let notif_queue = []; + +function notif(notif_c) { + let notification_area = document.querySelector(".statusline .notification-area"); + + if (notification_area.querySelector(".notification") === null) { + let notif_elem = document.createElement("div"); + notif_elem.className = "notification"; + notif_elem.appendChild(notif_c[0]); + notif_elem.appendChild(notif_c[1]); + + notification_area.appendChild(notif_elem); + + setTimeout(() => { + notification_area.removeChild(notif_elem); + if (notif_queue.length > 0) { + notif(notif_queue.pop()); + } + }, 5000); + } else { + notif_queue.push(notif_c); + } +} + +export function notif_alert(alert_str) { + let div = document.createElement("div"); + div.className = "notification-content"; + + let text = document.createElement("p"); + text.innerHTML = alert_str; + div.appendChild(text); + + let icon = document.createElement("i"); + icon.className = "alert fas fa-exclamation-triangle"; + div.appendChild(icon); + + notif([icon, div]); +} + +export function notif_warn(alert_str) { + let div = document.createElement("div"); + div.className = "notification-content"; + + let text = document.createElement("p"); + text.innerHTML = alert_str; + div.appendChild(text); + + let icon = document.createElement("i"); + icon.className = "warning fas fa-exclamation-triangle"; + div.appendChild(icon); + + notif([icon, div]); +} + +export function notif_info(info_str) { + let div = document.createElement("div"); + div.className = "notification-content"; + + let text = document.createElement("p"); + text.innerHTML = info_str; + div.appendChild(text); + + let icon = document.createElement("i"); + icon.className = "info fas fa-info-circle"; + div.appendChild(icon); + + notif([icon, div]); +} + +export function notif_success(success_str) { + let div = document.createElement("div"); + div.className = "notification-content"; + + let text = document.createElement("p"); + text.innerHTML = success_str; + div.appendChild(text); + + let icon = document.createElement("i"); + icon.className = "success fas fa-check-circle"; + div.appendChild(icon); + + notif([icon, div]); +} diff --git a/src/manifest.webmanifest b/src/manifest.webmanifest new file mode 100644 index 0000000..4306e2b --- /dev/null +++ b/src/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "background_color": "#454545", + "theme_color": "#5ac775", + "name": "HOVERBIT Controller", + "short_name": "HOVERBIT BLE", + "display": "fullscreen", + "start_url": "/hoverbit-ble/", + "orientation": "landscape", + "icons": [ + { + "src": "img/icon.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/src/scss/styles.scss b/src/scss/styles.scss new file mode 100644 index 0000000..b72acef --- /dev/null +++ b/src/scss/styles.scss @@ -0,0 +1,280 @@ +@import '@fortawesome/fontawesome-free/css/all.css'; + +$background-base: #454545; +$foreground-base: #9c9c9c; +$background-z1: #333333; +$foreground-z1: #b5b5b5; +$background-z2: #252525; +$foreground-z2: #b5b5b5; + +$color-danger: #c32222; +$color-info: #0374a8; +$color-success: #008000; +$color-warning: #d99b0b; + +html, body { + margin: 0; + padding: 0; + /* width: 100%; + height: 100%; */ + font-family: monospace; + overflow: hidden; + font-family: 'Kufam', cursive; +} + +body { + // margin: 10px; + background-color: $background-base; + color: $foreground-base; +} + +h1 { + font-size: 10px; + margin: 0; + color: $foreground-base; +} + +.statusline { + position: absolute; + display: flex; + bottom: 0; + left: 0; + height: 40px; + /* padding-left: 5px; */ + width: calc(100%); + font-size: 12px; + margin: 0; + align-items: center; + + background-color: $background-z1; + i { + font-size: 14px; + margin-right: 5px; + } + + &-item { + display: none; + align-items: flex-start; + margin-left: 5px; + margin-right: 5px; + font-size: 13px; + + color: $foreground-z1; + + .connected & { display: flex; } + } + + &-item:first-child { + margin-left: 10px; + margin-left: calc(env(safe-area-inset-left) + 10px); + } + + &-item:last-child { + margin-right: 10px; + margin-right: calc(env(safe-area-inset-right) + 10px); + } +} + +.connection { + display: flex; + color: $color-danger; + + &:after { + display: block; + content: "DISCONNECTED"; + } + + .connected & { color: $color-success; } + .connected &:after { + display: block; + content: "CONNECTED"; + } +} + +.arm { + color: $color-danger; + + &:after { + content: 'DISARMED'; + display: block; + } + + .armed & {color: $color-success; } + .armed &:after { + content: 'ARMED'; + display: block; + } +} + +.ping { + margin-left: auto; + + i { + -webkit-animation: ping-fade-out 2s forwards; /* Safari 4+ */ + -moz-animation: ping-fade-out 2s forwards; /* Fx 2+ */ + -o-animation: ping-fade-out 2s forwards; /* Opera 12+ */ + animation: ping-fade-out 2s forwards; /* IE 10+, Fx 29+ */ + } + + @-webkit-keyframes ping-fade-out { + 0% { opacity: 1; } + 100% { opacity: 0; } + } +} + +.acc-string { + display: none; + position: absolute; + top: 60px; + left: calc(50% - 50px - 10px); + width: 100px; + text-align: center; + background-color: #272727; + padding: 0 10px; + border-radius: 10px; + font-size: 9px; + + .connected & { display: block; } +} + +.button-center { + position: absolute; + display: none; + margin-top: 5px; + border: none; + background-color: $background-z1; + color: $foreground-z1; + padding: 10px; + width: 140px; + height: 40px; + left: calc(50% - 70px); + font-weight: 400; + font-size: 15px; + + .armed & { display: block; } + &-top { top: 10px; } + &-bottom { bottom: 50px; } +} + +#btn_connect { display: block; } +.connected #btn_connect { display: none; } +.connected #btn_disconnect { display: block; } +.connected #btn_arm { display: block; } +.armed #btn_arm { display: none; } + +#btn_connect, #btn_disconnect { + text-shadow: 0 0 5px #c3c3c3, 0 0 10px #636363; +} +#btn_arm, #btn_disarm { + color: red; + text-shadow: 0 0 5px #c53f3f, 0 0 10px #c53f3f; +} +#btn_disarm{ + color: #00ff15; + text-shadow: 0 0 5px #11b91f, 0 0 10px #297b30; +} + +.joystick { + position: absolute; + width: 200px; + height: 200px; + top: calc(50% - 100px - 10px); /* 50% - half height - statusline height */ + /* border: 1px solid pink; */ + + &-left { left: calc(25% - 100px); } + &-right { left: calc(75% - 100px); } +} + + +.landscape-warning { + display: none; + position: absolute; + z-index: 9999999; + width: 100%; + height: 100%; + padding-top: 50%; + background-color: $background-base; + text-align: center; + + &-content { + position: relative; + top: -50px; + } + i { font-size: 5em; } + h1 { + font-size: 1em; + margin: 10px; + } + button { + border: none; + background-color: $background-z1; + color: $foreground-z1; + padding: 10px; + width: 140px; + height: 40px; + } + + .ignore-landscape-warning & { display: none !important; } +} + +@media screen and (orientation:portrait) { + .landscape-warning { + display: block; + } +} + +.notification { + background-color: $background-z2; + color: $foreground-z2; + overflow: hidden; + display: flex; + align-items: baseline; + + &-area { + height: 100%; + width: 100%; + overflow: hidden; + margin-left: 5px; + margin-right: 5px; + + -webkit-animation: fade-in 0.5s linear; /* Safari 4+ */ + -moz-animation: fade-in 0.5s linear; /* Fx 2+ */ + -o-animation: fade-in 0.5s linear; /* Opera 12+ */ + animation: fade-in 0.5s linear; /* IE 10+, Fx 29+ */ + } + + &-content { + margin-left: 5px; + margin-right: 5px; + width: 100%; + overflow: hidden; + } + + i { + margin-left: 5px; + margin-right: 5px; + } + + i.alert { color: $color-danger; } + i.info { color: $color-info; } + i.success { color: $color-success; } + i.warning { color: $color-warning; } + + p { + width: max-content; + -webkit-animation: scroll-text 5s linear; /* Safari 4+ */ + -moz-animation: scroll-text 5s linear; /* Fx 2+ */ + -o-animation: scroll-text 5s linear; /* Opera 12+ */ + animation: scroll-text 5s linear; /* IE 10+, Fx 29+ */ + } + + @-webkit-keyframes scroll-text { + 0% { margin-left: 100%; } + 100% { margin-left: -100%; } + } + + @-webkit-keyframes fade-in { + 0% { opacity: 1; } + 100% { opacity: 0; } + } +} diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 0000000..bc3f42d --- /dev/null +++ b/src/service-worker.js @@ -0,0 +1,61 @@ +var APP_PREFIX = 'hoverbitcontroller' // Identifier for this app (this needs to be consistent across every cache update) +var VERSION = 'version_01' // Version of the off-line cache (change this value everytime you want to update cache) +var CACHE_NAME = APP_PREFIX + VERSION +var URLS = [ // Add URL you want to cache in this list. + '/hoverbit-ble/', // If you have separate JS/CSS files, + '/hoverbit-ble/index.html', // add path to those files here + '/hoverbit-ble/styles.css', + '/hoverbit-ble/microbit.umd.js', + '/hoverbit-ble/script.js' +] + +// Respond with cached resources +self.addEventListener('fetch', function (e) { + console.log('fetch request : ' + e.request.url) + e.respondWith( + caches.match(e.request).then(function (request) { + if (request) { // if cache is available, respond with cache + console.log('responding with cache : ' + e.request.url) + return request + } else { // if there are no cache, try fetching request + console.log('file is not cached, fetching : ' + e.request.url) + return fetch(e.request) + } + + // You can omit if/else for console.log & put one line below like this too. + // return request || fetch(e.request) + }) + ) +}) + +// Cache resources +self.addEventListener('install', function (e) { + e.waitUntil( + caches.open(CACHE_NAME).then(function (cache) { + console.log('installing cache : ' + CACHE_NAME) + return cache.addAll(URLS) + }) + ) +}) + +// Delete outdated caches +self.addEventListener('activate', function (e) { + e.waitUntil( + caches.keys().then(function (keyList) { + // `keyList` contains all cache names under your username.github.io + // filter out ones that has this app prefix to create white list + var cacheWhitelist = keyList.filter(function (key) { + return key.indexOf(APP_PREFIX) + }) + // add current cache name to white list + cacheWhitelist.push(CACHE_NAME) + + return Promise.all(keyList.map(function (key, i) { + if (cacheWhitelist.indexOf(key) === -1) { + console.log('deleting cache : ' + keyList[i] ) + return caches.delete(keyList[i]) + } + })) + }) + ) +}) |