diff --git a/docker/openwebrx/docker-compose.yml b/docker/openwebrx/docker-compose.yml
new file mode 100644
index 0000000..6801fec
--- /dev/null
+++ b/docker/openwebrx/docker-compose.yml
@@ -0,0 +1,42 @@
+services:
+ openwebrx:
+ image: 'slechev/openwebrxplus-softmbe:latest'
+ container_name: openwebrx_docker
+ restart: unless-stopped
+ ports:
+ - '8073:8073'
+ - '5678:5678'
+ environment:
+ TZ: Europe/Berlin
+ FORWARD_LOCALPORT_1234: 5678
+ # OPENWEBRX_ADMIN_USER: admin
+ # OPENWEBRX_ADMIN_PASSWORD: 123465
+ HEALTHCHECK_USB_1df7_3000: 1 # SDRPlay Healthcheck
+ HEALTHCHECK_USB_0bda_2838: 1 # NeSDR Smart Healthcheck
+ devices:
+ - /dev/bus/usb:/dev/bus/usb
+ volumes:
+ - ./etc:/etc/openwebrx
+ - ./var:/var/lib/openwebrx
+ - ./plugins:/usr/lib/python3/dist-packages/htdocs/plugins
+
+# if you want your container to restart automatically if the HEALTHCHECK fails
+# (see here: https://stackoverflow.com/a/48538213/420585)
+ openwebrx_autoheal:
+ restart: unless-stopped
+ container_name: openwebrx_docker_autoheal
+ image: willfarrell/autoheal
+ environment:
+ - AUTOHEAL_CONTAINER_LABEL=all
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+
+ restarter:
+ image: docker:cli
+ container_name: openwebrx_docker_restarter
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ./restarter:/restarter
+ entrypoint: ["/bin/sh", "-c"]
+ command: ["chmod +x /restarter/script.sh && /restarter/script.sh"]
+ restart: unless-stopped
diff --git a/docker/openwebrx/plugins/receiver/antenna_switcher/README.md b/docker/openwebrx/plugins/receiver/antenna_switcher/README.md
new file mode 100644
index 0000000..7680851
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/antenna_switcher/README.md
@@ -0,0 +1,57 @@
+---
+layout: page
+title: "OpenWebRX+ Receiver Plugin: Antenna Switcher"
+permalink: /receiver/antenna_switcher
+---
+
+This is a `receiver` plugin to add antenna switching functionality for Raspberry Pi devices, providing logical levels on their GPIO ports that correspond to the user's antenna selection via buttons on the OWRX's front-end.
+
+It consists of a **front-end** and **back-end** part.
+
+The front-end is a standard OpenWebRX+ plugin and its installation does not differ from the standard way you [install plugins](/openwebrxplus-plugins/#load-plugins):
+
+## I. Front-end installation
+
+### Load
+
+Add this line in your `init.js` file:
+
+```js
+Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/antenna_switcher/antenna_switcher.js');
+```
+
+#### Back-end URL
+
+You probably don't want to change the default back-end instance URL, but if you do want to, you can do it from `init.js`, before or after the plugin loading, with:
+
+`Plugins.antenna_switcher.API_URL = 'HOST:PORT/antenna_switch'`
+
+### init.js
+
+Learn how to [load plugins](/openwebrxplus-plugins/#load-plugins).
+
+## II. Back-end installation
+
+The back-end installation script **is designed to work with OpenWebRX+ installed from the repository as a package**. You need to adjust it in case you want to use it inside a Docker container.
+
+For the back-end, download and run the [install_backend.sh](install_backend.sh) Bash script **as root**:
+
+```sh
+wget 'https://0xaf.github.io/openwebrxplus-plugins/receiver/antenna_switcher/install_backend.sh'
+chmod +x ./install_backend.sh
+sudo ./install_backend.sh
+```
+
+The script will download and install the Flask back-end in a Python virtual environment located in `/opt/antenna_switcher`, so you don't have to worry about libraries messing up your system's default Python installation.
+
+It will also create a systemd service file called `antenna_switcher`, then start and enable it.
+
+The nginx configuration will be extended to provide reverse proxying from the OpenWebRX front-end *(default port 8073)* to the Flask back-end *(port 8075, binding only on 127.0.0.1)*.
+
+## Configuration
+
+Things like which GPIO pins to raise high when the corresponding button from the front-end is selected can be configured in `/opt/antenna_switcher/antenna_switcher.cfg`.
+
+```sh
+sudo ${EDITOR-nano} /opt/antenna_switcher/antenna_switcher.cfg
+```
diff --git a/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.cfg b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.cfg
new file mode 100644
index 0000000..dea138c
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.cfg
@@ -0,0 +1 @@
+antenna_pins=[23,24]
diff --git a/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.css b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.css
new file mode 100644
index 0000000..2ea6c40
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.css
@@ -0,0 +1,24 @@
+.openwebrx-ant-grid {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin: -5px -5px 0 0;
+}
+
+.openwebrx-ant-grid {
+ margin: 0;
+ white-space: nowrap;
+ flex: 1 0 38px;
+ margin: 5px 5px 0 0;
+}
+
+@supports(gap: 5px) {
+ .openwebrx-ant-grid {
+ margin: 0;
+ gap: 5px;
+ }
+
+ .openwebrx-ants-grid {
+ margin: 0;
+ }
+}
diff --git a/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.js b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.js
new file mode 100644
index 0000000..a2b1d3f
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.js
@@ -0,0 +1,98 @@
+// Antenna switch UI plugin for OpenWebRX+
+// License: MIT
+// Original Example File Copyright (c) 2023 Stanislav Lechev [0xAF], LZ2SLL
+// Modified by DL9UL to provide UI buttons used to call a WebAPI
+// Re-written by Dimitar Milkov, LZ2DMV to a more optimized and clean state
+
+Plugins.antenna_switcher.API_URL ??= `${window.location.origin}/antenna_switch`;
+
+// Init function of the plugin
+Plugins.antenna_switcher.init = function () {
+
+ let antennaNum = 0;
+ const buttons = [];
+
+ // Function to send a command via POST
+ function sendCommand(command) {
+ fetch(Plugins.antenna_switcher.API_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ command }),
+ })
+ .then(response => {
+ if (!response.ok) throw new Error('Network response was not ok');
+ return response.json();
+ })
+ .then(data => {
+ const response = data.payload.response;
+ const match = response.match(/^n:(\d)$/);
+ if (match) {
+ antennaNum = parseInt(match[1], 10);
+ if (!buttonsCreated) createButtons();
+ } else {
+ updateButtonState(response);
+ }
+ })
+ .catch(error => console.error('Error:', error));
+ }
+
+ // Function to update the button state based on the active antenna
+ function updateButtonState(activeAntenna) {
+ buttons.forEach((button, index) => {
+ button.classList.toggle('highlighted', (index + 1).toString() === activeAntenna);
+ });
+ }
+
+ // Create buttons and add them to the container
+ function createButtons() {
+ // Create antenna section
+ const antSection = document.createElement('div');
+ antSection.classList.add('openwebrx-section');
+
+ const antPanelLine = document.createElement('div');
+ antPanelLine.classList.add('openwebrx-ant', 'openwebrx-panel-line');
+ antSection.appendChild(antPanelLine);
+
+ const antGrid = document.createElement('div');
+ antGrid.classList.add('openwebrx-ant-grid');
+ antPanelLine.appendChild(antGrid);
+
+ for (let i = 1; i <= antennaNum; i++) {
+ const button = createButton(i);
+ buttons.push(button);
+ antGrid.appendChild(button);
+ }
+
+ // Section Divider to hide ANT panel
+ const antSectionDivider = document.createElement('div');
+ antSectionDivider.id = 'openwebrx-section-ant';
+ antSectionDivider.classList.add('openwebrx-section-divider');
+ antSectionDivider.onclick = () => UI.toggleSection(antSectionDivider);
+ antSectionDivider.innerHTML = "▾ Antenna";
+
+ // Append the container above the "openwebrx-section-modes"
+ const targetElement = document.getElementById('openwebrx-section-modes');
+ targetElement.parentNode.insertBefore(antSectionDivider, targetElement);
+ targetElement.parentNode.insertBefore(antSection, targetElement);
+
+ buttonsCreated = true;
+ }
+
+ function createButton(i) {
+ const button = document.createElement('div');
+ button.id = `owrx-ant-button-${i}`;
+ button.classList.add('openwebrx-button');
+ button.textContent = `ANT ${i}`;
+ button.onclick = () => sendCommand(String(i));
+ return button;
+ }
+
+ let buttonsCreated = false;
+
+ sendCommand('n');
+ sendCommand('s');
+
+ setInterval(() => sendCommand('s'), 2000);
+
+ return true;
+};
diff --git a/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.py b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.py
new file mode 100644
index 0000000..f35d08c
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/antenna_switcher/antenna_switcher.py
@@ -0,0 +1,103 @@
+# Simple Flask backend to work with the JavaScript frontend for the antenna switcher by DL9UL
+# License: Apache 2
+# Copyright (c) 2024 Dimitar Milkov, LZ2DMV
+
+# pip install flask flask-cors RPi.GPIO
+
+from flask import Flask, request, jsonify
+from flask_cors import CORS
+import RPi.GPIO as GPIO
+import os
+
+config_file = 'antenna_switcher.cfg'
+antenna_file = 'ant'
+
+def load_config():
+ config = {}
+ if os.path.exists(config_file):
+ with open(config_file, 'r') as file:
+ for line in file:
+ name, value = line.strip().split('=')
+ if name == 'antenna_pins':
+ config[name] = eval(value)
+ return config
+
+config = load_config()
+antenna_pins = config.get('antenna_pins')
+
+if antenna_pins is None:
+ raise ValueError("Configuration file is missing required values.")
+
+num_antennas = len(antenna_pins)
+
+app = Flask(__name__)
+CORS(app)
+GPIO.setmode(GPIO.BCM)
+
+for pin in antenna_pins:
+ GPIO.setup(pin, GPIO.OUT)
+
+def read_active_antenna():
+ if os.path.exists(antenna_file):
+ with open(antenna_file, 'r') as file:
+ return file.read().strip()
+ return None
+
+def write_active_antenna(value):
+ with open(antenna_file, 'w') as file:
+ file.write(value)
+
+def set_gpio_for_antenna(antenna_id):
+ for i, pin in enumerate(antenna_pins):
+ GPIO.output(pin, GPIO.HIGH if i == antenna_id else GPIO.LOW)
+
+def initialize_antenna():
+ active_antenna = read_active_antenna()
+ if active_antenna and active_antenna.isdigit() and 1 <= int(active_antenna) <= num_antennas:
+ set_gpio_for_antenna(int(active_antenna) - 1)
+ else:
+ set_gpio_for_antenna(0)
+ write_active_antenna('1')
+
+@app.route('/antenna_switch', methods=['POST'])
+def antennaswitch():
+ data = request.get_json()
+ command = data.get('command')
+
+ if command.isdigit() and 1 <= int(command) <= num_antennas:
+ return set_antenna(command)
+ elif command == 's':
+ return get_active_antenna()
+ elif command == 'n':
+ return get_antenna_count()
+ else:
+ return jsonify({'error': 'Invalid command'}), 400
+
+def get_active_antenna():
+ active_antenna = read_active_antenna()
+ if active_antenna is None:
+ return jsonify(payload={'response': '0'})
+ return jsonify(payload={'response': active_antenna})
+
+def get_antenna_count():
+ return jsonify(payload={'response': f'n:{num_antennas}'})
+
+def set_antenna(antenna_id):
+ active_antenna = read_active_antenna()
+ if active_antenna == antenna_id:
+ return jsonify(payload={'response': antenna_id})
+
+ try:
+ set_gpio_for_antenna(int(antenna_id) - 1)
+ write_active_antenna(antenna_id)
+ return jsonify(payload={'response': antenna_id})
+ except Exception as e:
+ print(f"Error: {e}")
+ return jsonify(payload={'response': '0'}), 500
+
+if __name__ == '__main__':
+ try:
+ initialize_antenna()
+ app.run(host='127.0.0.1', port=8075)
+ finally:
+ GPIO.cleanup()
diff --git a/docker/openwebrx/plugins/receiver/antenna_switcher/install_backend.sh b/docker/openwebrx/plugins/receiver/antenna_switcher/install_backend.sh
new file mode 100644
index 0000000..9b82544
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/antenna_switcher/install_backend.sh
@@ -0,0 +1,101 @@
+#!/bin/bash
+# Install script for the antenna_switcher backend.
+# Assumes a Debian / Raspberry Pi OS / Ubuntu system with an APT
+# package manager and a OpenWebRX+ installation from the repository.
+#
+# You would need to adjust the script if you want to use it inside
+# a Docker container, but it is probably a bad idea anyway.
+#
+# License: Apache 2
+# Copyright (c) 2024 Dimitar Milkov, LZ2DMV
+
+apt-get update
+apt-get install -y python3 python3-pip python3-venv nginx
+
+# download backend
+mkdir -p /opt/antenna_switcher
+pushd /opt/antenna_switcher
+repo=https://raw.githubusercontent.com/0xAF/openwebrxplus-plugins/main/receiver/antenna_switcher
+wget --no-clobber -O antenna_switcher.py "$repo"/antenna_switcher.py
+wget --no-clobber -O antenna_switcher.cfg "$repo"/antenna_switcher.cfg
+echo "1" > ant
+# prepare venv
+python3 -m venv venv
+source venv/bin/activate
+pip install flask flask-cors RPi.GPIO
+deactivate
+popd
+
+# systemd service
+cat << _EOF_ > /etc/systemd/system/antenna_switcher.service
+[Unit]
+Description=Antenna Switcher Backend
+After=network.target
+
+[Service]
+User=root
+Group=root
+WorkingDirectory=/opt/antenna_switcher
+ExecStart=/opt/antenna_switcher/venv/bin/python3 -m antenna_switcher
+
+[Install]
+WantedBy=multi-user.target
+_EOF_
+
+systemctl daemon-reload
+systemctl enable --now antenna_switcher
+
+# nginx configuration
+mkdir -p /etc/nginx/snippets
+cat << _EOF_ > /etc/nginx/snippets/antenna_switcher.conf
+set \$antennaBackend 127.0.0.1:8075;
+location /antenna_switch {
+ proxy_pass http://\$antennaBackend;
+ proxy_http_version 1.1;
+ proxy_buffering off;
+
+ # required for websockets
+ proxy_set_header Upgrade \$http_upgrade;
+ proxy_set_header Connection \$http_connection;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+}
+_EOF_
+
+nginx_owrx_config="/etc/nginx/sites-available/openwebrx"
+
+if [ -f "$nginx_owrx_config" ]; then
+ # some black magic with sed to add include line in the end of the server block
+ grep -qF 'include snippets/antenna_switcher.conf' $nginx_owrx_config || sed -ri.bak \
+ ':a;N;$!ba;s/}[\s\r\n]*$/\n\tinclude snippets\/antenna_switcher.conf;\n}/' \
+ $nginx_owrx_config
+
+ systemctl restart varnish nginx
+else
+ echo "You need to create your nginx site in /etc/nginx/sites-enabled and include snippets/antenna_switcher.conf."
+ echo "Sample config [/etc/nginx/sites-enabled/openwebrx]:"
+ cat << _EOF_
+
+server {
+ listen 80 default_server;
+ listen [::]:80 default_server;
+ listen 443 ssl default_server;
+ listen [::]:443 ssl default_server;
+ gzip off;
+ include snippets/snakeoil.conf;
+
+ set \$upstream 127.0.0.1:8073; # OWRX
+ location / {
+ proxy_pass http://\$upstream;
+ proxy_http_version 1.1;
+ proxy_buffering off;
+
+ # required for websockets
+ proxy_set_header Upgrade \$http_upgrade;
+ proxy_set_header Connection \$http_connection;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ }
+
+ include snippets/antenna_switcher.conf;
+}
+_EOF_
+fi
diff --git a/docker/openwebrx/plugins/receiver/colorful_spectrum/README.md b/docker/openwebrx/plugins/receiver/colorful_spectrum/README.md
new file mode 100644
index 0000000..7c076c5
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/colorful_spectrum/README.md
@@ -0,0 +1,23 @@
+---
+layout: page
+title: "OpenWebRX+ Receiver Plugin: Colorful Spectrum"
+permalink: /receiver/colorful_spectrum
+---
+
+This `receiver` plugin will colorify your spectrum analyzer.
+
+## Preview
+
+
+
+## Load
+
+Add this line in your `init.js` file:
+
+```js
+Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/colorful_spectrum/colorful_spectrum.js');
+```
+
+## init.js
+
+Learn how to [load plugins](/openwebrxplus-plugins/#load-plugins).
diff --git a/docker/openwebrx/plugins/receiver/colorful_spectrum/colorful_spectrum.js b/docker/openwebrx/plugins/receiver/colorful_spectrum/colorful_spectrum.js
new file mode 100644
index 0000000..ad9ad1b
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/colorful_spectrum/colorful_spectrum.js
@@ -0,0 +1,65 @@
+/*
+ * Plugin: colorify the spectrum analyzer.
+ *
+ * License: MIT
+ * Copyright (c) 2023 Stanislav Lechev [0xAF], LZ2SLL
+ */
+
+// do not load CSS for this plugin
+Plugins.colorful_spectrum.no_css = true;
+
+Plugins.colorful_spectrum.init = async function () {
+
+ // Check if utils plugin is loaded
+ if (!Plugins.isLoaded('utils', 0.1)) {
+ // try to load the utils plugin
+ await Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/utils/utils.js');
+
+ // check again if it was loaded successfully
+ if (!Plugins.isLoaded('utils', 0.1)) {
+ console.error('colorful_spectrum plugin depends on "utils >= 0.1".');
+ return false;
+ } else {
+ Plugins._debug('Plugin "utils" has been loaded as dependency.');
+ }
+ }
+
+ // wait for OWRX to initialize
+ $(document).on('event:owrx_initialized', function () {
+ Plugins.utils.wrap_func(
+ 'draw',
+ function (orig, thisArg, args) {
+ return true;
+ },
+ function (res, thisArg, args) {
+ var vis_freq = get_visible_freq_range();
+ var vis_start = 0.5 - (center_freq - vis_freq.start) / bandwidth;
+ var vis_end = 0.5 - (center_freq - vis_freq.end) / bandwidth;
+ var data_start = Math.round(fft_size * vis_start);
+ var data_end = Math.round(fft_size * vis_end);
+ var data_width = data_end - data_start;
+ var data_height = Math.abs(thisArg.max - thisArg.min);
+ var spec_width = thisArg.el.offsetWidth;
+ var spec_height = thisArg.el.offsetHeight;
+ if (spec_width <= data_width) {
+ var x_ratio = data_width / spec_width;
+ var y_ratio = spec_height / data_height;
+ for (var x = 0; x < spec_width; x++) {
+ var data = (thisArg.data[data_start + ((x * x_ratio) | 0)]);
+ var y = (data - thisArg.min) * y_ratio;
+ thisArg.ctx.fillRect(x, spec_height, 1, -y);
+ if (data) {
+ var c = Waterfall.makeColor(data);
+ thisArg.ctx.fillStyle = "rgba(" +
+ c[0] + ", " + c[1] + ", " + c[2] + ", " +
+ (25 + y * 2) + "%)";
+ }
+ }
+ }
+ },
+ spectrum
+ );
+ });
+
+ return true;
+}
diff --git a/docker/openwebrx/plugins/receiver/colorful_spectrum/colorful_spectrum.png b/docker/openwebrx/plugins/receiver/colorful_spectrum/colorful_spectrum.png
new file mode 100644
index 0000000..da40b71
Binary files /dev/null and b/docker/openwebrx/plugins/receiver/colorful_spectrum/colorful_spectrum.png differ
diff --git a/docker/openwebrx/plugins/receiver/connect_notify/README.md b/docker/openwebrx/plugins/receiver/connect_notify/README.md
new file mode 100644
index 0000000..95dda43
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/connect_notify/README.md
@@ -0,0 +1,29 @@
+---
+layout: page
+title: "OpenWebRX+ Receiver Plugin: Connect/Disconnect notifications"
+permalink: /receiver/connect_notify
+---
+
+This `receiver` plugin will:
+
+* Send a chat message to all users when you connect/disconnect to SDR
+* Show notification when another user is connected/disconnected to SDR
+
+The plugin depends on [notify](https://0xaf.github.io/openwebrxplus-plugins/receiver/notify) plugin.
+
+## Preview
+
+
+
+## Load
+
+Add this line in your `init.js` file:
+
+```js
+Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/notify/notify.js');
+Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/connect_notify/connect_notify.js');
+```
+
+## init.js
+
+Learn how to [load plugins](/openwebrxplus-plugins/#load-plugins).
diff --git a/docker/openwebrx/plugins/receiver/connect_notify/connect_notify.js b/docker/openwebrx/plugins/receiver/connect_notify/connect_notify.js
new file mode 100644
index 0000000..37ef54f
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/connect_notify/connect_notify.js
@@ -0,0 +1,56 @@
+/*
+ * Plugin: User connect notification
+ *
+ * - Send a chat message to all users when you connect to SDR
+ * - Show notification when another user is connected to SDR
+ *
+ * License: MIT
+ * Copyright (c) 2023 Stanislav Lechev [0xAF], LZ2SLL
+ */
+
+// no css for this plugin
+Plugins.connect_notify.no_css = true;
+
+// Initialize the plugin
+Plugins.connect_notify.init = async function () {
+
+ if (!Plugins.isLoaded('notify', 0.1)) {
+ // try to load the notify plugin
+ await Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/notify/notify.js');
+
+ // check again if it was loaded successfully
+ if (!Plugins.isLoaded('notify', 0.1)) {
+ console.error('connect_notify plugin depends on "notify >= 0.1".');
+ return false;
+ } else {
+ Plugins._debug('Plugin "notify" has been loaded as dependency.');
+ }
+ }
+
+ Plugins.connect_notify.last = -1;
+ $(document).on('server:clients:after', function (e, data) {
+ var users = data - 1;
+ if (Plugins.connect_notify.last < 0) {
+ // this is our connection, so initialize.
+ Plugins.connect_notify.last = users;
+ // delay 100ms so the page initialize
+ setTimeout(function () {
+ var nick = LS.has('chatname') ? LS.loadStr('chatname') : 'Unknown';
+ Chat.sendMessage('Connected.', nick);
+ }, 100);
+ return;
+ }
+ if (users != Plugins.connect_notify.last) {
+ Plugins.notify.show('User ' + (
+ (users > Plugins.connect_notify.last) ? 'Connected' : 'Disconnected'
+ ));
+ Plugins.connect_notify.last = users;
+ }
+ });
+ $(window).bind('beforeunload', function () {
+ var nick = LS.has('chatname') ? LS.loadStr('chatname') : 'Unknown';
+ Chat.sendMessage('Disconnected.', nick);
+ });
+
+ return true;
+}
diff --git a/docker/openwebrx/plugins/receiver/connect_notify/connect_notify.png b/docker/openwebrx/plugins/receiver/connect_notify/connect_notify.png
new file mode 100644
index 0000000..8ef087a
Binary files /dev/null and b/docker/openwebrx/plugins/receiver/connect_notify/connect_notify.png differ
diff --git a/docker/openwebrx/plugins/receiver/doppler/README.md b/docker/openwebrx/plugins/receiver/doppler/README.md
new file mode 100644
index 0000000..77288ab
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/doppler/README.md
@@ -0,0 +1,34 @@
+---
+layout: page
+title: "OpenWebRX+ Receiver Plugin: Doppler"
+permalink: /receiver/doppler
+---
+
+This `receiver` plugin will track the Doppler shift frequency of a chosen satellite. Useful for SSTV/Packet.
+
+This plugin started as a port of [work](https://github.com/studentkra/OpenWebRX-Doppler) by [Sergey Osipov](https://github.com/studentkra).
+Then I switched to [CelesTrak JSON API](https://celestrak.org/) and created Satellite Finder modal window.
+
+## Preview
+
+
+
+## Usage
+
+ 1. Open the Satellite finder window to choose a satellite or enter the SatID if you know it.
+ 2. Click TRACK.
+
+The Satellite Finder window will help you find a satellite and will give useful information on each satellite.
+
+
+## Load
+
+Add this line in your `init.js` file:
+
+```js
+Plugins.load('https://0xaf.github.io/openwebrxplus-plugins/receiver/doppler/doppler.js');
+```
+
+## init.js
+
+Learn how to [load plugins](/openwebrxplus-plugins/#load-plugins).
diff --git a/docker/openwebrx/plugins/receiver/doppler/doppler.css b/docker/openwebrx/plugins/receiver/doppler/doppler.css
new file mode 100644
index 0000000..ebcb284
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/doppler/doppler.css
@@ -0,0 +1,182 @@
+:root {
+ --satellite-bg: #333333;
+ --satellite-fg: #ffffff;
+ --satellite-bg-bars: #575757;
+ --satellite-grad-1: #373737;
+ --satellite-grad-2: #4F4F4F;
+}
+
+body.has-theme {
+ --satellite-bg: var(--theme-color1);
+ --satellite-bg-bars: var(--theme-color2);
+ --satellite-grad-1: var(--theme-gradient-color1);
+ --satellite-grad-2: var(--theme-gradient-color2);
+}
+
+.blocker {
+ z-index: 2001 !important;
+}
+
+.modal {
+ z-index: 2002 !important;
+}
+
+.modal.satellite-modal {
+ height: 500px;
+ background-color: var(--satellite-bg);
+ color: var(--satellite-fg);
+ padding: 2rem 0.5rem;
+}
+
+.satellite-modal-header {
+ background-color: var(--satellite-bg-bars);
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+ padding: 4px 10px;
+ font-variant: small-caps;
+ text-align: center;
+}
+
+.satellite-modal-body {
+ position: absolute;
+ top: 1.7rem;
+ bottom: 2.3rem;
+ left: 0;
+ right: 0;
+ padding: 0;
+}
+
+.satellite-modal-footer {
+ background-color: var(--satellite-bg-bars);
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ border-bottom-right-radius: 8px;
+ border-bottom-left-radius: 8px;
+ padding: 4px 10px;
+ display: flex;
+ justify-content: flex-end;
+}
+
+
+.satellite-modal-tabs-wrapper {
+ max-width: 50rem;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.satellite-modal-tabs {
+ position: relative;
+ background-color: var(--satellite-bg-bars);
+ /* background: linear-gradient(to bottom, var(--satellite-grad-1) 0%, var(--satellite-grad-2) 100%); */
+ /* height: 14.75rem; */
+}
+
+.satellite-modal-tabs::before,
+.satellite-modal-tabs::after {
+ content: "";
+ display: table;
+}
+
+.satellite-modal-tabs::after {
+ clear: both;
+}
+
+.satellite-modal-tab {
+ float: left;
+}
+
+.satellite-modal-tab-switch {
+ display: none;
+}
+
+.satellite-modal-tab-label {
+ position: relative;
+ display: block;
+ line-height: 1.75em;
+ height: 2em;
+ padding: 0 .6em;
+ /* background: linear-gradient(to bottom, var(--satellite-grad-1) 0%, var(--satellite-grad-2) 100%); */
+ background: var(--satellite-bg-bars);
+ border-right: 0.125rem solid var(--satellite-bg);
+ color: var(--satellite-fg);
+ cursor: pointer;
+ top: 0;
+ transition: all 0.25s;
+}
+
+.satellite-modal-tab-label:hover {
+ border-top: var(--satellite-bg) solid 1px;
+ top: -0.25rem;
+ transition: top 0.25s;
+}
+
+.satellite-modal-tab-content {
+ height: 12rem;
+ position: absolute;
+ z-index: 1;
+ top: 2em;
+ left: 0;
+ padding: .25rem .25rem;
+ background: linear-gradient(to bottom, var(--satellite-grad-1) 0%, var(--satellite-grad-2) 100%);
+ color: var(--satellite-fg);
+ /* border-bottom: 0.25rem solid var(--satellite-bg); */
+ opacity: 0;
+ transition: all 0.35s;
+ height: 404px;
+ width: calc(100% - .5rem);
+}
+
+.satellite-modal-tab-switch:checked+.satellite-modal-tab-label {
+ background: linear-gradient(to bottom, var(--satellite-grad-1) 0%, var(--satellite-grad-2) 100%);
+ color: var(--satellite-fg);
+ border-bottom: 0;
+ border-right: 0.125rem solid var(--satellite-bg);
+ transition: all 0.35s;
+ z-index: 1;
+ top: -0.0625rem;
+}
+
+.satellite-modal-tab-switch:checked+label+.satellite-modal-tab-content {
+ z-index: 2;
+ opacity: 1;
+ transition: all 0.35s;
+}
+
+.satellite-table {
+ width: 100%;
+ cursor: pointer;
+}
+
+.satellite-table>tbody>tr:hover {
+ background-color: var(--satellite-bg-bars);
+ filter: drop-shadow(3px 3px 10px black);
+}
+
+#satellite-name {
+ border: 0px solid;
+ width: 126px;
+ align-content: center;
+ text-align: center;
+ height: 15px;
+ overflow: hidden;
+}
+
+#satellite-row {
+ padding: 4px 0px;
+ justify-content: space-between;
+}
+
+#satellite-input {
+ width: 54px;
+}
+
+#satellite-track {
+ width: 48px;
+ text-align: center;
+}
\ No newline at end of file
diff --git a/docker/openwebrx/plugins/receiver/doppler/doppler.js b/docker/openwebrx/plugins/receiver/doppler/doppler.js
new file mode 100644
index 0000000..8d7fe6b
--- /dev/null
+++ b/docker/openwebrx/plugins/receiver/doppler/doppler.js
@@ -0,0 +1,450 @@
+/*
+ * Plugin: Doppler - Track satellite frequency (Doppler shift/effect)
+ *
+ * This plugin started as a port of Sergey Osipov work:
+ * https://github.com/studentkra/OpenWebRX-Doppler
+ * Then evolved.
+ *
+ * License: MIT
+ * Copyright (c) 2024 Stanislav Lechev [0xAF], LZ2SLL
+ *
+ * TODO:
+ * - Option to integrate sat bookmarks
+ * - Associate the bookmarks with modulation and SatID so it can be easily tracked, once bookmark is clicked.
+ * - Scan the LocalStorage and remove old groups.
+ */
+
+
+// no css for this plugin
+// Plugins.doppler.no_css = true;
+
+// Initialize the plugin
+Plugins.doppler.init = async function () {
+ // Check if utils plugin is loaded
+ // if (!Plugins.isLoaded('utils', 0.3)) {
+ // console.error('Example plugin depends on "utils >= 0.3".');
+ // return false;
+ // }
+
+ // await Plugins._load_script('http://192.168.175.99:8080/doppler/sat.js').catch(function () {
+ await Plugins._load_script('https://0xaf.github.io/openwebrxplus-plugins/receiver/doppler/sat.js').catch(function() {
+ throw ("Cannot load satellite-js script.");
+ });
+
+ await Plugins._load_script('https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.5.0/lz-string.min.js').catch(function () {
+ throw ("Cannot load lz-string script.");
+ });
+
+
+ await Plugins._load_style('https://cdnjs.cloudflare.com/ajax/libs/jquery-modal/0.9.1/jquery.modal.min.css').catch(function () {
+ throw ("Cannot load jquery-modal style.");
+ });
+
+ await Plugins._load_script('https://cdnjs.cloudflare.com/ajax/libs/jquery-modal/0.9.1/jquery.modal.min.js').catch(function () {
+ throw ("Cannot load jquery-modal script.");
+ }).then(() => {
+ // $.modal.defaults.escapeClose = true;
+ // $.modal.defaults.clickClose = false;
+ // $.modal.defaults.showClose = false;
+ });
+
+ // initialize on load
+ if ($("#satellite-row").length < 1) {
+ $(".openwebrx-modes").after(`
+
+
+
Open SAT Finder
+
TRACK
+
+ `);
+
+ var modalTabs = `
+
+
+ `;
+
+ var groups = Object.keys(Plugins.doppler.satelliteGroups);
+ for (let i = 0; i < groups.length; i++) {
+ modalTabs += `
+
+
+
+
+
+
+
+
+
+ | SatID |
+ Name |
+ Elev |
+ Az |
+ Dist |
+ Visible |
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ modalTabs += `
+
+
+ `;
+
+ $('#satellite-row').append(`
+
+
+
+ ${modalTabs}
+
Select Category
+
+
+
+ `);
+
+ $('#satellite-modal').on($.modal.BEFORE_CLOSE, function(event, modal) {
+ if (Plugins.doppler.scanRunning !== undefined) {
+ Plugins.doppler.toggleRefresh(Plugins.doppler.scanRunning);
+ }
+ });
+
+ $("#satellite-name").click(() => {
+ // window.open("https://tle.ivanstanojevic.me/", "_blank");
+ $('#satellite-modal').modal({
+ escapeClose: true,
+ clickClose: false,
+ showClose: false,
+ });
+ });
+
+ $("#satellite-track").click(() => {
+ if (Plugins.doppler.intervalId) {
+ Plugins.doppler.stop_tracker();
+ return;
+ }
+
+ if (($('#satellite-input').val()).length < 1) {
+ Plugins.doppler.stop_tracker("NOT FOUND!");
+ return;
+ }
+
+ var satObj = null;
+ if (Plugins.doppler.lastGroupName) {
+ try {
+ var store = JSON.parse(LZString.decompress(LS.loadStr('satellites.' + Plugins.doppler.lastGroupName)));
+ satObj = store.data.find(obj => obj.NORAD_CAT_ID === parseInt($('#satellite-input').val() ,10));
+ Plugins.doppler.start_tracker(satObj);
+ } catch (e) { satObj = null; }
+ }
+
+ if (!satObj) {
+ fetch('https://celestrak.org/NORAD/elements/gp.php?CATNR=' + $('#satellite-input').val() + '&FORMAT=JSON')
+ .then(response => response.json())
+ .then(data2 => {
+ Plugins.doppler.start_tracker(data2[0])
+ })
+ .catch((error) => {
+ console.error(error);
+ Plugins.doppler.stop_tracker(error);
+ });
+ }
+ });
+ } // initialize
+
+ return true; // plugin init return
+}
+
+Plugins.doppler.stop_tracker = function (info) {
+ clearInterval(Plugins.doppler.intervalId);
+ Plugins.doppler.intervalId = undefined;
+ $('#satellite-track').removeClass('highlighted').text('TRACK');
+ $('#satellite-name').text((info && info.length) ? info : "Open SAT Finder");
+}
+
+Plugins.doppler.start_tracker = function (obj) {
+ // var satrec = satellite.twoline2satrec(line1, line2);
+ // var satrec = satellite.object2satrec(obj);
+ var satrec = satellite.json2satrec(obj);
+ if (satrec.error>0) {
+ Plugins.doppler.stop_tracker("Bad SAT Data");
+ return;
+ };
+ var asl = $('.webrx-rx-desc').text().match(/ASL:\s*(\d+)\s*m/);
+ asl = (asl && asl[1] && parseInt(asl[1]) > 0) ? parseInt(asl[1]) / 1000 : 0;
+ var receiverPos = Utils.getReceiverPos();
+ if (!receiverPos || !receiverPos.lat || !receiverPos.lon) {
+ Plugins.doppler.stop_tracker("Set Receiver Position");
+ return;
+ }
+
+ $("#satellite-name").text(obj.OBJECT_NAME);
+ $("#satellite-track").addClass('highlighted').text("STOP");
+ var demodulator = $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator();
+ var startFreq = demodulator.get_offset_frequency() + center_freq;
+ Plugins.doppler.intervalId = setInterval(() => {
+ var demodulator = $('#openwebrx-panel-receiver').demodulatorPanel().getDemodulator();
+ newFreq = Plugins.doppler.getDoppler(satrec, asl, receiverPos.lat, receiverPos.lon, startFreq);
+ demodulator.set_offset_frequency(newFreq - center_freq);
+ console.debug(`Doppler Freq: ${newFreq}`);
+ }, 1000);
+}
+
+Plugins.doppler.getDoppler = function (satrec, asl, lat, lon, center) {
+ var positionAndVelocity = satellite.propagate(satrec, new Date());
+ var positionEci = positionAndVelocity.position;
+ var velocityEci = positionAndVelocity.velocity;
+ var observerGd = {
+ longitude: satellite.degreesToRadians(lon),
+ latitude: satellite.degreesToRadians(lat),
+ height: asl
+ };
+ var gmst = satellite.gstime(new Date());
+ var positionEcf = satellite.eciToEcf(positionEci, gmst);
+ var observerEcf = satellite.geodeticToEcf(observerGd);
+ var velocityEcf = satellite.eciToEcf(velocityEci, gmst);
+ var dopplerFactor = satellite.dopplerFactor(observerEcf, positionEcf, velocityEcf);
+ return (Math.round(dopplerFactor * center));
+}
+
+Plugins.doppler.tabChange = function (id, tab) {
+ var options = `