Compare commits

...

24 Commits

Author SHA1 Message Date
606c1bd46b Added frontend code for limits 2026-06-03 19:09:45 +01:00
6231059b82 Added limit API endpoint 2026-06-03 18:59:09 +01:00
10520db877 Added basic safe limit functionality 2026-06-03 18:50:21 +01:00
57869b16f7 Fixed argument help (fuck you, Tango) 2026-06-03 18:11:00 +01:00
e4100decdb Implemented backend for pin 2026-06-03 18:05:10 +01:00
8a37c1736b Fixed shocker pin argument order & type 2026-06-03 18:00:17 +01:00
739f3ec5b4 Added basic PIN to web interface, partially implemented back-end 2026-06-03 17:55:26 +01:00
a13f73cb99 Added rate limiting 2026-06-02 21:45:32 +01:00
80f54299a6 Added license 2026-06-02 21:42:22 +01:00
570a788237 Added WHSPAH to the requirements (I'm so dumb) 2026-06-02 21:41:47 +01:00
ee402a1072 Added minimum and maximum to transmitter ID and channel input 2026-06-02 21:34:34 +01:00
7ee402c1d0 Updated styles.css, changed default slider values to 0 2026-06-02 21:31:08 +01:00
48a74104eb Added extremely simple small-screen support 2026-06-02 18:40:39 +01:00
832b700e06 Removed placeholder text, updated CSS to keep content box size. 2026-06-02 18:31:07 +01:00
956d13a00f Updated backgrounds to have blur 2026-06-02 18:27:00 +01:00
195c70d526 Added tiling background, as well as img folder 2026-06-02 18:24:16 +01:00
57bfd63126 Updated slider value visuals to use their own ID, styles.css updated to make sliders bigger and more user friendly. 2026-06-02 17:46:14 +01:00
0f946fa8b0 Oops, I forgot the shock button (The entire point of this) 2026-06-02 17:33:52 +01:00
203a363a6c Merge branch 'main' of https://gitea.imadumbass.dog/Brosef/WHSPAH-Web-Interface 2026-06-02 17:31:19 +01:00
b298c11d36 Added input tags to HTML that call JavaScript functions, as well as changing JavaScript code to match HTML input IDs. 2026-06-02 17:28:46 +01:00
01e2359315 Added transmitter.close() to end of file 2026-06-02 17:06:08 +01:00
8faf77918c Added comments 2026-06-02 17:05:44 +01:00
36102b3ae9 Added function calls for UI 2026-06-02 16:58:08 +01:00
6b0ce137b7 Added transmit frontend call 2026-06-02 16:45:54 +01:00
7 changed files with 208 additions and 5 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Brosef & Tango
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
app.py
View File

@ -4,77 +4,131 @@ A simple Flask application for interacting with WHSPAH shockers.
import argparse import argparse
import logging import logging
import random
import json import json
import os import os
from flask_limiter.util import get_remote_address
from flask_limiter import Limiter
import waitress.server import waitress.server
import flask import flask
import whspah import whspah
# Used in transmit() to convert the `action` string to a WHSPAH MODE
ACTION_MAP = {'shock': whspah.MODES.SHOCK, ACTION_MAP = {'shock': whspah.MODES.SHOCK,
'vibrate': whspah.MODES.VIBRATE, 'vibrate': whspah.MODES.VIBRATE,
'beep': whspah.MODES.BEEP} 'beep': whspah.MODES.BEEP}
app = flask.Flask(__name__, static_folder='./www/') app = flask.Flask(__name__, static_folder='./www/')
limiter = Limiter(
get_remote_address,
app=app,
storage_uri="memory://",
)
@app.route('/', defaults={'path': 'index.html'}) @app.route('/', defaults={'path': 'index.html'})
@app.route('/<path:path>') @app.route('/<path:path>')
@limiter.exempt
def staticPage(path): def staticPage(path):
""" """
Returns anything requested in the static folder. Returns anything requested in the static folder.
""" """
# Check for an index.html file in the specified path
if os.path.isfile(os.path.join(app.static_folder, path, 'index.html')): if os.path.isfile(os.path.join(app.static_folder, path, 'index.html')):
# If one's found, append it to the path
path = os.path.join(path, 'index.html') path = os.path.join(path, 'index.html')
# Return the file
return flask.send_from_directory(app.static_folder, path) return flask.send_from_directory(app.static_folder, path)
@app.route('/transmit', methods=['POST'], strict_slashes=False) @app.route('/transmit', methods=['POST'], strict_slashes=False)
@limiter.limit("1/second")
def transmit(): def transmit():
""" """
Transmits the data contained within the POST request through WHSPAH. Transmits the data contained within the POST request through WHSPAH.
""" """
# Get the POST data and load it, assuming it's in a JSON format.
data = json.loads(flask.request.data) data = json.loads(flask.request.data)
# Try to get all required fields
try: try:
txID = int(data['transmitterID']) txID = int(data['transmitterID'])
channel = int(data['channel']) channel = int(data['channel'])
action = ACTION_MAP[data['action']] action = ACTION_MAP[data['action']]
pin = int(data['shockerPin'])
intensity = int(data.get('intensity', 0)) intensity = int(data.get('intensity', 0))
lucal = bool(data.get('lucalEncoded', False)) lucal = bool(data.get('lucalEncoded', False))
# If any of those failed, return an error.
except (ValueError, KeyError): except (ValueError, KeyError):
return {'success': False, 'message': 'Request must contain the following keys:\n'+ return {'success': False, 'message': 'Request must contain the following keys:\n'+
'txID: int,\n'+ 'txID: int,\n'+
'channel: int,\n'+ 'channel: int,\n'+
'action: "shock", "vibrate" or "beep",\n'+ 'action: "shock", "vibrate" or "beep",\n'+
'pin: int,\n'+
'intensity (optional): int\n'+ 'intensity (optional): int\n'+
'lucalEncoded (optional): bool'}, 400 'lucalEncoded (optional): bool'}, 400
if pin != app.config['pin']:
return {'success': False, 'message': 'Unauthorised'}, 401
if intensity > app.config['limit']:
return {'success': False, 'message': 'Exceeded Safe Limit'}, 406
# Send the data to WHSPAH
tx: whspah.Transmitter = app.config['transmitter'] tx: whspah.Transmitter = app.config['transmitter']
tx.transmit(txID, channel, action, intensity, lucal) tx.transmit(txID, channel, action, intensity, lucal)
# Return a success message
return {'success': True}, 200 return {'success': True}, 200
@app.route('/limit', methods=['GET'], strict_slashes=False)
@limiter.limit("1/second")
def getLimit():
"""
Simply returns the safe limit.
"""
return {'success': True, 'limit': app.config['limit']}
if __name__ == '__main__': if __name__ == '__main__':
# Parse console arguments
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('-d', '--debug', action='store_true') parser.add_argument('-d', '--debug', action='store_true')
parser.add_argument('--ip', type=str, default='0.0.0.0') parser.add_argument('--ip', type=str, default='0.0.0.0')
parser.add_argument('--port', type=int, default=8000) parser.add_argument('--port', type=int, default=8000)
parser.add_argument('--pin', type=int, default=random.randint(1000, 9999),
help='The authorisation pin, defaults to a random number '+
'between 1000 and 9999')
parser.add_argument('--limit', type=int, default=99,
help='The same limit for intensity.')
args = parser.parse_args() args = parser.parse_args()
# Sets the logging version based on --debug
if args.debug: if args.debug:
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
else: else:
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
# Connect to a WHSPAH device
app.config['transmitter'] = whspah.Transmitter() app.config['transmitter'] = whspah.Transmitter()
# Configure pin
app.config['pin'] = args.pin
print(f'Running with pin {args.pin}.')
# Configure Limit
app.config['limit'] = args.limit
print(f'Running with safe limit {args.limit}.')
if args.debug: if args.debug:
# If in debug mode, run Flask's built in server
app.run(host=args.ip, port=args.port, debug=True) app.run(host=args.ip, port=args.port, debug=True)
else: else:
# Otherwise, use Waitress
server = waitress.server.create_server(app, host=args.ip, port=args.port) server = waitress.server.create_server(app, host=args.ip, port=args.port)
print(f'Serving at http://{args.ip}:{args.port}/') print(f'Serving at http://{args.ip}:{args.port}/')
try: try:
@ -82,3 +136,5 @@ if __name__ == '__main__':
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
server.close() server.close()
app.config['transmitter'].close()

View File

@ -1,2 +1,4 @@
Flask-Limiter
waitress waitress
whspah
flask flask

BIN
www/img/backgroundtile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -3,6 +3,7 @@
<head> <head>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
<script src="/main.js"></script> <script src="/main.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head> </head>
<body> <body>
@ -11,7 +12,26 @@
<h3>A simple locally hosted web interface for WHSPAH.</h3> <h3>A simple locally hosted web interface for WHSPAH.</h3>
</div> </div>
<div class="contentBox"> <div class="contentBox">
<p>Just pretend there's a bunch of buttons and sliders here. I'm just kind of yapping to test the CSS. Wow look at that slider and those buttons. So cool.</p> <h2>transmitterID (0-65535)</h2>
<input type="number" value="0" min="0" max="65535" id="transmitterIDInput">
<h2>Channel Input (1-3)</h2>
<input type="number" value="1" min="1" max="3" id="channelIDInput">
<h2>LucalEncoded?</h2>
<input type="checkbox" id="lucalEncodedInput">
<h2>PIN</h2>
<input type="number" id="shockerPinInput">
<h2>Shock</h2>
<input type="range" min="0" max="99" value="0" id="shockIntensity" oninput="document.getElementById('sliderValueFirst').value = this.value" class="sliderInput">
<br>
<output id="sliderValueFirst" class="sliderValue">0</output>
<br>
<button onclick="shock();" class="sliderButton">Shock!</button>
<h2>Vibrate</h2>
<input type="range" min="0" max="99" value="0" id="vibrateIntensity" oninput="document.getElementById('sliderValueSecond').value = this.value" class="sliderInput">
<br>
<output id="sliderValueSecond" class="sliderValue">0</output>
<br>
<button onclick="vibrate();" class="sliderButton">Vibrate!</button>
</div> </div>
</body> </body>
</html> </html>

View File

@ -1 +1,84 @@
console.debug('Main loaded!'); async function GET(path) {
return new Promise(function (onSuccess, onError) {
let xhr = new XMLHttpRequest();
xhr.open('GET', path);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
onSuccess(JSON.parse(xhr.responseText));
} else {
onError(xhr);
}
}
}
xhr.onerror = () => onError(xhr);
xhr.send(null);
});
}
async function POST(path, data={}) {
return new Promise(function (onSuccess, onError) {
var xhr = new XMLHttpRequest();
xhr.open('POST', path, true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
onSuccess(xhr);
} else {
onError(xhr);
}
}
};
xhr.onerror = () => onError(xhr);
xhr.send(JSON.stringify(data));
});
}
function assert(condition, message) {
if (!condition) {
throw message || "Assertion failed";
}
}
async function transmit(transmitterID, channel, action, shockerPin, intensity=0, lucalEncoded=false) {
assert(typeof transmitterID === 'number');
assert(typeof channel === 'number');
assert(['shock', 'vibrate', 'beep'].includes(action));
assert(typeof shockerPin === 'number');
assert(typeof intensity === 'number');
assert(typeof lucalEncoded === 'boolean');
POST('/transmit', {transmitterID: transmitterID, channel: channel, action: action, shockerPin: shockerPin, intensity: intensity, lucalEncoded: lucalEncoded});
}
async function txFromUI(action, intensity=0) {
let transmitterID = Number(document.getElementById('transmitterIDInput').value);
let channel = Number(document.getElementById('channelIDInput').value);
let lucalEncoded = document.getElementById('lucalEncodedInput').checked;
let shockerPin = Number(document.getElementById('shockerPinInput').value);
transmit(transmitterID, channel, action, shockerPin, intensity, lucalEncoded);
}
async function shock() {
let intensity = Number(document.getElementById('shockIntensity').value);
txFromUI('shock', intensity);
}
async function vibrate() {
let intensity = Number(document.getElementById('vibrateIntensity').value);
txFromUI('vibrate', intensity);
}
async function beep() {
txFromUI('beep');
}
window.addEventListener('load', () => {
GET('/limit').then((data) => {
document.getElementById('shockIntensity').max = data.limit;
document.getElementById('vibrateIntensity').max = data.limit;
});
});

View File

@ -2,20 +2,25 @@ body {
/* Anything but light mode, please. */ /* Anything but light mode, please. */
background-color: #000000; background-color: #000000;
color: #FFFFFF; color: #FFFFFF;
background-image: url("/img/backgroundtile.png");
background-size: 10%;
} }
.whspahTitle{ .whspahTitle{
background-color: #363636; background-color: #363636da;
backdrop-filter: blur(10px);
text-align: center; text-align: center;
width: max-content; width: fit-content;
margin:0 auto; margin:0 auto;
margin-bottom: 1%; margin-bottom: 1%;
padding: 1%; padding: 1%;
border-radius: 25px; border-radius: 25px;
max-width: 100vw;
} }
.contentBox{ .contentBox{
background-color: #363636; background-color: #363636da;
backdrop-filter: blur(10px);
text-align: center; text-align: center;
width: max-content; width: max-content;
margin:0 auto; margin:0 auto;
@ -23,4 +28,20 @@ body {
padding: 1%; padding: 1%;
border-radius: 25px; border-radius: 25px;
max-width: 80%; max-width: 80%;
min-width: 30%;
}
.sliderInput{
width: 90%;
}
.sliderValue{
background-color: rgb(31, 31, 31);
padding-left: 3%;
padding-right: 3%;
border-radius: 15px;
}
.sliderButton{
margin: 1%;
} }