├── .dockerignore ├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── README.md ├── app.py ├── config.ini ├── package-lock.json ├── package.json ├── requirements.txt ├── static ├── css │ └── styles.css └── js │ └── scripts.js └── templates ├── buttons.jinja ├── config.jinja ├── goto.jinja ├── head.jinja ├── header.jinja ├── index.jinja ├── scripts.jinja ├── status.jinja └── toasts.jinja /.dockerignore: -------------------------------------------------------------------------------- 1 | # git 2 | .git 3 | .gitattributes 4 | .gitignore 5 | 6 | # Python 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check Out Repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v1 18 | 19 | - name: Login to Docker Hub 20 | uses: docker/login-action@v1 21 | with: 22 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 23 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 24 | 25 | - name: Generate Timestamp 26 | id: timestamp 27 | run: echo "::set-output name=timestamp::$(date +%s)" 28 | 29 | - name: Build and Push Docker Image 30 | uses: docker/build-push-action@v2 31 | with: 32 | context: . 33 | push: true 34 | tags: kaistarkk/wumps 35 | platforms: linux/amd64,linux/arm64,linux/arm/v6 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | .github -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.9.6-alpine3.14 3 | 4 | # Set the working directory to /app 5 | WORKDIR /app 6 | 7 | COPY requirements.txt . 8 | 9 | RUN pip install --trusted-host pypi.python.org -r requirements.txt 10 | 11 | # Copy the current directory contents into the container at /app 12 | COPY . /app 13 | 14 | # Make port 8009 available to the world outside this container 15 | EXPOSE 9008 16 | 17 | # Define environment variable 18 | ENV WUMPS=app.py 19 | 20 | # Run app.py when the container launches 21 | CMD ["python", "app.py"] 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WUMPS 2 | 3 | ## Web Utility for Managing Power States 4 | 5 | ### For \*arr users with power bills! 6 | 7 | [![Build and Push Docker Image](https://github.com/KaiStarkk/wumps/actions/workflows/docker-image.yml/badge.svg)](https://github.com/KaiStarkk/wumps/actions/workflows/docker-image.yml) 8 | 9 | ![image](https://user-images.githubusercontent.com/1722064/229474685-60f1c7e5-431e-4185-94af-21889cab82e1.png) 10 | 11 | When starting out with self-hosted media servers, many users run \*arr apps on their personal machines. It's not uncommon to have Windows services installed for Sonarr, Radarr, Readarr, Prowlarr, Jellyfin, Audiobookshelf, Jellyseerr, VueTorrent, Dashy... and the like. 12 | 13 | This can be inefficient, as personal computers (let's be honest, _gaming computers_) run on power-intensive hardware, and are wasting energy while idle if not in use. Not to mention the noise and heat coming out of that ATX case! This is compounded when the computer is left on 24/7 _in case_ you might need to add something to a DVR or watch 15 minutes of a show on your lunch break. 14 | 15 | Yes it's possible to buy a $2000 NAS and run Unraid/OMV, set up redundant disks, split your libraries onto dedicated drives/volumes so only one spins up when you use it (etc. etc. etc.) but who has the money and time for that! 16 | 17 | This is a very simple app intended to provide a dashboard for powering personal machines on and off instead. 18 | 19 | ## Installation 20 | 21 | NOTE: This app is intended to be installed on a lightweight client such as an old Raspberry Pi, on the same local network as your media server_gaming computer_. If you happen to have an old RPI1/0 lying around, remember they run on ARMv6 so some extra work may be necessary to compile binaries if you don't use docker. 22 | 23 | 1. Pull the latest image, build, and start the app using 24 | 25 | `docker run -d --restart always --network host kaistarkk/wumps` 26 | 27 | 2. Install [SleepOnLan](https://github.com/SR-G/sleep-on-lan) on any target machines that you need to be able to hibernate as well as wake. 28 | 29 | The recommended method of exposing this application to the internet is to use a Cloudflare Zero Trust tunnel. See the `cloudflared` documentation for further details on setting up a tunnel. 30 | 31 | WUMPS runs on port 9008 by default. 32 | 33 | ## Roadmap 34 | 35 | - [X] Improve install instructions 36 | - [X] Save configuration from the dashboard 37 | - [X] Tidy up UI for changing configuration 38 | - [ ] Add authentication 39 | - [ ] Switch between multiple saved hosts (tiles?) 40 | - [ ] Check for updates 41 | 42 | ## Contributing 43 | 44 | Current dependencies and stack: 45 | 46 | - [SleepOnLan](https://github.com/SR-G/sleep-on-lan) 47 | - Python3 (w/ wakeonlan, ping3, configparser) 48 | - Flask 49 | - HTML/CSS/JS (jinja) 50 | - Docker 51 | 52 | ## License 53 | 54 | Gifted to the public domain in a Boddhisattva-like act. 55 | For jurisdictions that don't permit gifting to the public domain, this software is licensed under BSD 0-clause: 56 | 57 | ``` 58 | Zero-Clause BSD 59 | ============= 60 | 61 | Permission to use, copy, modify, and/or distribute this software for 62 | any purpose with or without fee is hereby granted. 63 | 64 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL 65 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES 66 | OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE 67 | FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY 68 | DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN 69 | AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 70 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 71 | ``` 72 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, render_template, request 2 | from wakeonlan import send_magic_packet 3 | from ping3 import ping 4 | from configparser import ConfigParser 5 | 6 | app = Flask(__name__) 7 | config = ConfigParser() 8 | 9 | # helper function 10 | 11 | 12 | def reverse_mac(mac): 13 | """Reverses a MAC address and formats it with hyphens between each pair of characters.""" 14 | mac = mac.replace(':', '').replace('-', '') 15 | mac_pairs = [mac[i:i+2] for i in range(0, len(mac), 2)] 16 | reversed_mac = '-'.join(reversed(mac_pairs)) 17 | return reversed_mac.upper() 18 | 19 | # Index route 20 | 21 | 22 | @app.route('/') 23 | def index(): 24 | config.read('config.ini') 25 | DEFAULT_HOST = config['DEFAULTS']['DEFAULT_HOST'] 26 | DEFAULT_MAC = config['DEFAULTS']['DEFAULT_MAC'] 27 | DEFAULT_DESTINATION = config['DEFAULTS']['DEFAULT_DESTINATION'] 28 | return render_template('index.jinja', 29 | app=app, 30 | default_host=DEFAULT_HOST, 31 | default_mac=DEFAULT_MAC, 32 | default_destination=DEFAULT_DESTINATION) 33 | 34 | # Saving route 35 | 36 | 37 | @app.route('/save', methods=['POST']) 38 | def save(): 39 | DEFAULT_HOST = request.form['hostIP'] 40 | DEFAULT_MAC = request.form['mac-address'] 41 | DEFAULT_DESTINATION = request.form['destination'] 42 | 43 | config['DEFAULTS'] = { 44 | 'DEFAULT_HOST': DEFAULT_HOST, 45 | 'DEFAULT_MAC': DEFAULT_MAC, 46 | 'DEFAULT_DESTINATION': DEFAULT_DESTINATION 47 | } 48 | 49 | with open('config.ini', 'w') as configfile: 50 | config.write(configfile) 51 | 52 | # Note that flask will still have cached the .ini file, 53 | # so future gets to '/' will be reading from memory until the container restarts. 54 | 55 | # Return a simple JSON response with a 200 status code 56 | return jsonify({'message': 'Config saved successfully'}), 200 57 | 58 | # API endpoints 59 | 60 | 61 | @app.route('/api/wol/', methods=['GET']) 62 | def wol(mac): 63 | try: 64 | send_magic_packet(mac) 65 | return jsonify({'result': True}) 66 | except Exception as e: 67 | return jsonify({'result': False, 'error': str(e)}) 68 | 69 | 70 | @app.route('/api/sol/', methods=['GET']) 71 | def sol(mac): 72 | try: 73 | cam = reverse_mac(mac) 74 | send_magic_packet(cam) 75 | return jsonify({'result': True}) 76 | except Exception as e: 77 | return jsonify({'result': False, 'error': str(e)}) 78 | 79 | 80 | @app.route('/api/state/ip/', methods=['GET']) 81 | def state(ip): 82 | try: 83 | response_time = ping(ip, timeout=2) 84 | if response_time is False: 85 | return jsonify({'status': 'unknown'}) 86 | if response_time is not None: 87 | return jsonify({'status': 'awake'}) 88 | else: 89 | return jsonify({'status': 'asleep'}) 90 | except ping.PingError as e: 91 | return jsonify({'status': 'unknown', 'error': str(e)}) 92 | 93 | 94 | if __name__ == '__main__': 95 | app.run(host='0.0.0.0', port=9008) 96 | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [DEFAULTS] 2 | default_host = hostname.local 3 | default_mac = 00-00-00-00-00-00 4 | default_destination = hostname.local: 80 5 | 6 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WUMPS", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.3 2 | ping3==4.0.4 3 | wakeonlan==3.0.0 4 | Werkzeug==2.2.2 5 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #222 !important; 3 | } 4 | 5 | .status-indicator { 6 | font-size: 1.2rem; 7 | } 8 | 9 | .toast { 10 | min-width: 200px; 11 | } 12 | 13 | .btn-purple { 14 | background-color: purple; 15 | color: white; 16 | } 17 | 18 | .btn-orange { 19 | background-color: orange; 20 | color: white; 21 | } 22 | 23 | .text-input { 24 | background-color: #222222 !important; 25 | color: #ccc !important; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /static/js/scripts.js: -------------------------------------------------------------------------------- 1 | let wakeButton, sleepButton, statusIndicator, toast, toastBody; 2 | 3 | function sendPacket(endpoint, successMessage) { 4 | const macAddress = document.getElementById('mac-address').value; 5 | updateStatus(); 6 | fetch(`/api/${endpoint}/${macAddress}`) 7 | .then(parseJSON) 8 | .then((data) => { 9 | if (data.result) { 10 | showToast(successMessage); 11 | } else { 12 | showToast( 13 | data.error || 'An error occurred while sending the packet.' 14 | ); 15 | } 16 | }) 17 | .catch(() => 18 | showToast('Failed to send the packet. Check your connection.') 19 | ); 20 | } 21 | 22 | function updateStatus() { 23 | const hostIP = document.getElementById('hostIP').value; 24 | statusIndicator.innerText = '...'; 25 | statusIndicator.classList.remove('bg-danger', 'bg-success', 'bg-warning'); 26 | fetch(`/api/state/ip/${hostIP}`) 27 | .then(parseJSON) 28 | .then((data) => { 29 | if (data.error) { 30 | statusIndicator.innerText = 'Error'; 31 | statusIndicator.classList.add('bg-danger'); 32 | showToast(data.error); 33 | } else { 34 | statusIndicator.innerText = data.status; 35 | if (data.status === 'awake') { 36 | statusIndicator.classList.add('bg-success'); 37 | } else { 38 | statusIndicator.classList.add('bg-warning'); 39 | } 40 | } 41 | }) 42 | .catch(() => { 43 | statusIndicator.innerText = 'Error'; 44 | statusIndicator.classList.add('bg-danger'); 45 | showToast('Failed to update the status. Check your connection.'); 46 | }); 47 | } 48 | 49 | function showToast(message) { 50 | toastBody.innerText = message; 51 | toast.show(); 52 | } 53 | 54 | // Helper function 55 | function parseJSON(response) { 56 | return response.text().then((text) => { 57 | return text ? JSON.parse(text) : {}; 58 | }); 59 | } 60 | 61 | function init() { 62 | const wakeButton = document.getElementById('wake-button'); 63 | const sleepButton = document.getElementById('sleep-button'); 64 | const toggleFormBtn = document.getElementById('toggle-form-btn'); 65 | const configForm = document.getElementById('config-form'); 66 | statusIndicator = document.getElementById('status-indicator'); 67 | toast = new bootstrap.Toast(document.getElementById('toast')); 68 | toastBody = document.getElementById('toast-body'); 69 | 70 | wakeButton.addEventListener('click', () => 71 | sendPacket('wol', 'Wake packet sent.') 72 | ); 73 | sleepButton.addEventListener('click', () => 74 | sendPacket('sol', 'Sleep packet sent.') 75 | ); 76 | 77 | toggleFormBtn.addEventListener('click', () => { 78 | configForm.classList.toggle('d-none'); 79 | }); 80 | 81 | updateStatus(); 82 | setInterval(updateStatus, 8000); 83 | } 84 | 85 | document.addEventListener('DOMContentLoaded', init); 86 | 87 | $(function () { 88 | // Handle form submission 89 | $('#config-form').submit(function (event) { 90 | // Prevent default form submission behavior 91 | event.preventDefault(); 92 | 93 | // Serialize form data 94 | var formData = $(this).serialize(); 95 | 96 | // Submit form using AJAX 97 | $.ajax({ 98 | type: 'POST', 99 | url: '/save', 100 | data: formData, 101 | success: function (data) { 102 | // Handle successful form submission here 103 | console.log('Form submitted successfully'); 104 | }, 105 | error: function (xhr, status, error) { 106 | // Handle form submission error here 107 | console.error('Form submission failed: ' + error); 108 | }, 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /templates/buttons.jinja: -------------------------------------------------------------------------------- 1 |
2 | 5 | 8 |
9 | -------------------------------------------------------------------------------- /templates/config.jinja: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | 8 | 9 |
10 | 11 |
12 | 13 | 14 |
15 | 16 |
17 | 18 | 19 |
20 | 21 |
22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /templates/goto.jinja: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 |
9 |
10 | -------------------------------------------------------------------------------- /templates/head.jinja: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /templates/header.jinja: -------------------------------------------------------------------------------- 1 |

WUMPS

2 |

Web Utility for Managing Power States

3 | -------------------------------------------------------------------------------- /templates/index.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WUMPS - Web Utility for Managing Power States 7 | {% include "head.jinja" %} 8 | 9 | 10 | 11 |
14 | {% include "header.jinja" %} 15 | {% include "buttons.jinja" %} 16 | {% include "status.jinja" %} 17 | {% include "goto.jinja" %} 18 | {% include "toasts.jinja" %} 19 | {% include "config.jinja" %} 20 |
21 | 22 | {% include "scripts.jinja" %} 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /templates/scripts.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if app.debug %} 6 | 7 | {% endif %} 8 | 9 | -------------------------------------------------------------------------------- /templates/status.jinja: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Status:
4 |
5 |
6 |
...
7 |
8 |
9 | -------------------------------------------------------------------------------- /templates/toasts.jinja: -------------------------------------------------------------------------------- 1 | 11 | --------------------------------------------------------------------------------