Compare commits

..

18 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
7 changed files with 131 additions and 16 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.

40
app.py
View File

@ -4,9 +4,12 @@ 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
@ -19,8 +22,15 @@ ACTION_MAP = {'shock': whspah.MODES.SHOCK,
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.
@ -35,6 +45,7 @@ def staticPage(path):
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.
@ -48,6 +59,7 @@ def transmit():
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. # If any of those failed, return an error.
@ -56,9 +68,16 @@ def transmit():
'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 # 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)
@ -66,6 +85,14 @@ def transmit():
# Return a success message # 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 # Parse console arguments
@ -73,6 +100,11 @@ if __name__ == '__main__':
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 # Sets the logging version based on --debug
@ -84,6 +116,14 @@ if __name__ == '__main__':
# Connect to a WHSPAH device # 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 # 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)

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,23 +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> <h2>transmitterID (0-65535)</h2>
<input type="number" id="transmitterIDInput"> <input type="number" value="0" min="0" max="65535" id="transmitterIDInput">
<h2>Channel Input (1-3)</h2> <h2>Channel Input (1-3)</h2>
<input type="number" id="channelIDInput"> <input type="number" value="1" min="1" max="3" id="channelIDInput">
<h2>LucalEncoded?</h2> <h2>LucalEncoded?</h2>
<input type="checkbox" id="lucalEncodedInput"> <input type="checkbox" id="lucalEncodedInput">
<h2>PIN</h2>
<input type="number" id="shockerPinInput">
<h2>Shock</h2> <h2>Shock</h2>
<input type="range" min="0" max="99" id="shockIntensity" oninput="this.nextElementSibling.value = this.value"> <input type="range" min="0" max="99" value="0" id="shockIntensity" oninput="document.getElementById('sliderValueFirst').value = this.value" class="sliderInput">
<output>0</output>
<br> <br>
<output id="sliderValueFirst" class="sliderValue">0</output>
<br>
<button onclick="shock();" class="sliderButton">Shock!</button>
<h2>Vibrate</h2> <h2>Vibrate</h2>
<input type="range" min="0" max="99" id="vibrateIntensity" oninput="this.nextElementSibling.value = this.value"> <input type="range" min="0" max="99" value="0" id="vibrateIntensity" oninput="document.getElementById('sliderValueSecond').value = this.value" class="sliderInput">
<output>0</output>
<br> <br>
<button onclick="vibrate();">Vibrate!</button> <output id="sliderValueSecond" class="sliderValue">0</output>
<br>
<button onclick="vibrate();" class="sliderButton">Vibrate!</button>
</div> </div>
</body> </body>
</html> </html>

View File

@ -1,3 +1,21 @@
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={}) { async function POST(path, data={}) {
return new Promise(function (onSuccess, onError) { return new Promise(function (onSuccess, onError) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@ -24,13 +42,14 @@ function assert(condition, message) {
} }
} }
async function transmit(transmitterID, channel, action, intensity=0, lucalEncoded=false) { async function transmit(transmitterID, channel, action, shockerPin, intensity=0, lucalEncoded=false) {
assert(typeof transmitterID === 'number'); assert(typeof transmitterID === 'number');
assert(typeof channel === 'number'); assert(typeof channel === 'number');
assert(['shock', 'vibrate', 'beep'].includes(action)); assert(['shock', 'vibrate', 'beep'].includes(action));
assert(typeof shockerPin === 'number');
assert(typeof intensity === 'number'); assert(typeof intensity === 'number');
assert(typeof lucalEncoded === 'boolean'); assert(typeof lucalEncoded === 'boolean');
POST('/transmit', {transmitterID: transmitterID, channel: channel, action: action, intensity: intensity, lucalEncoded: lucalEncoded}); POST('/transmit', {transmitterID: transmitterID, channel: channel, action: action, shockerPin: shockerPin, intensity: intensity, lucalEncoded: lucalEncoded});
} }
@ -39,7 +58,8 @@ async function txFromUI(action, intensity=0) {
let transmitterID = Number(document.getElementById('transmitterIDInput').value); let transmitterID = Number(document.getElementById('transmitterIDInput').value);
let channel = Number(document.getElementById('channelIDInput').value); let channel = Number(document.getElementById('channelIDInput').value);
let lucalEncoded = document.getElementById('lucalEncodedInput').checked; let lucalEncoded = document.getElementById('lucalEncodedInput').checked;
transmit(transmitterID, channel, action, intensity, lucalEncoded); let shockerPin = Number(document.getElementById('shockerPinInput').value);
transmit(transmitterID, channel, action, shockerPin, intensity, lucalEncoded);
} }
async function shock() { async function shock() {
@ -55,3 +75,10 @@ async function vibrate() {
async function beep() { async function beep() {
txFromUI('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%;
} }