Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7c8ba12
added queue log type
douxxtech Jan 23, 2026
139ec12
shared,client: protocol 2.0.2
douxxtech Jan 23, 2026
18d6451
temp queue implementatio, there are still a lot of things to do
douxxtech Jan 23, 2026
d5ea249
queue: cleaned up, and made some maintenance (claude)
douxxtech Jan 24, 2026
6cf258e
local: implemented the queue system
douxxtech Jan 24, 2026
3498eab
server,local: update doc to include queue
douxxtech Jan 24, 2026
3485f6c
scripts: temporarly changed install script to target queue branch
douxxtech Jan 24, 2026
f22b6d0
queue,local,server: pausing queue on manual actions, like start, stop…
douxxtech Jan 24, 2026
8a0d8ff
installation: added queue.py to the always component
douxxtech Jan 24, 2026
cc975a1
queue,local,server: fixed issue where the queue system self-triggers …
douxxtech Jan 24, 2026
1310ba4
local,server: more manual toggles
douxxtech Jan 24, 2026
9cae0bc
queue: added loop to the toggle ocmmand
douxxtech Jan 26, 2026
a2005c1
queue: using shlex to parse the toggle args
douxxtech Jan 26, 2026
09f0c4f
queue: updated queue show logs to show if it is looping
douxxtech Jan 26, 2026
1076dfb
queue: fixed help formatting
douxxtech Jan 26, 2026
a98a398
scripts: Revert "scripts: temporarly changed install script to target…
douxxtech Jan 27, 2026
2e4df90
added queue to supported commands
douxxtech Jan 27, 2026
11b9ec6
client: sending file not found as Commands.END
douxxtech Jan 27, 2026
a31a5ef
server: if a message is provided in the Commands.END, it will be show…
douxxtech Jan 27, 2026
5f53b31
readme: improved features list
douxxtech Jan 27, 2026
90d74e7
server,local: updated help command to include the queue command
douxxtech Jan 27, 2026
fb119ee
latestver: updated to 2.0.2
douxxtech Jan 27, 2026
7b22143
=--- v1.0.7-annea ---=
douxxtech Jan 27, 2026
a5cf18f
Merge branch 'main' into queue
douxxtech Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,15 @@ BotWave lets you broadcast audio over FM radio using Raspberry Pi devices. It su
## Features

- **Server-Client Architecture**: Manage multiple Raspberry Pi clients from a central server.
- **Standalone Client**: Run a client without a central server for single-device broadcasting.
- **Audio Broadcasting**: Broadcast audio files over FM radio.
- **File Upload**: Upload audio files to clients for broadcasting.
- **Remote Management**: Start, stop, and manage broadcasts remotely.
- **Authentication**: Client-server authentication with passkeys.
- **Protocol Versioning**: Ensure compatibility between server and clients.
- **Live Broadcasting**: Stream live output from any application in real time.
- **Queue System**: Manage playlists and multiple audio files at once.
- **Task Automation**: Run commands automatically on events and start on system boot.

## Requirements
> All requirements can be installed automatically via the installer, see below.
Expand Down
1 change: 1 addition & 0 deletions assets/installation.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"shared/logger.py",
"shared/morser.py",
"shared/protocol.py",
"shared/queue.py",
"shared/socket.py",
"shared/sstv.py",
"shared/syscheck.py",
Expand Down
2 changes: 1 addition & 1 deletion assets/latest.ver.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.1
2.0.2
19 changes: 19 additions & 0 deletions assets/releases.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@
# - Release description: detailed information about the release
# - =--- END ---=: marks the end of the release

=--- v1.0.7-annea ---=
**What changed:**
- **Protocol**:
- New `Commands.END` for the client to report a broadcast end or failure
- Version `2.0.2`

- **New features**:
- `queue` command to manage your playlist. See [the wiki](https://github.com/dpipstudio/botwave/wiki/Queue-system)

- **Usage changes**:
- Now local client and client can take the `--talk` argument to show [`PiWave`](https://git.douxx.tech/piwave) debug logs. Disabled by default.

---

Related:
- #33
- #37
=--- END ---=

=--- v1.0.6-citrus ---=
**What changed:**
- Updated default rt on server to reflect the filename, not the targets
Expand Down
2 changes: 1 addition & 1 deletion bin/bw-nandl
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ EDITOR="${EDITOR:-nano}"

# supported handlers and commands
VALID_PREFIXES=("l_onready" "l_onstart" "l_onstop" "s_onready" "s_onstart" "s_onstop" "s_onconnect" "s_ondisconnect" "s_onwsjoin" "s_onwsleave")
VALID_COMMANDS=("start" "stop" "list" "upload" "dl" "handlers" "<" "help" "exit" "kick" "rm" "sync" "lf" "sstv" "morse" "live")
VALID_COMMANDS=("start" "stop" "list" "upload" "dl" "handlers" "<" "help" "exit" "kick" "rm" "sync" "lf" "sstv" "morse" "live" "queue")

list_handlers() {
echo ""
Expand Down
12 changes: 11 additions & 1 deletion client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ async def _handle_start_broadcast(self, kwargs: dict):

file_path = os.path.join(self.upload_dir, filename)
if not os.path.exists(file_path):
response = ProtocolParser.build_response(Commands.ERROR, f"File not found: {filename}")
response = ProtocolParser.build_response(Commands.END, f"File not found: {filename}")
await self.ws_client.send(response)
return

Expand Down Expand Up @@ -511,6 +511,16 @@ async def _delayed_broadcast(self, file_path, filename, frequency, ps, rt, pi, l
async def _start_broadcast(self, file_path, filename, frequency, ps, rt, pi, loop):
async def finished():
Log.info("Playback finished, stopping broadcast...")

try:
response = ProtocolParser.build_command(
Commands.END,
filename=filename
)
await self.ws_client.send(response)
except Exception as e:
Log.error(f"Error notifying server of broadcast end: {e}")

await self._stop_broadcast()

async with self.broadcast_lock:
Expand Down
3 changes: 3 additions & 0 deletions local/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ Once the client is running, you can use the following commands:

- `live`: Start a live broadcast.
- Usage: `botwave> live [frequency] [loop] [ps] [rt] [pi]`

- `queue`: Manages the queue. See the [`Main/Queue system`](https://github.com/dpipstudio/botwave/wiki/Queue-system) wiki page for more details.
- Usage: `botwave> queue ?`

- `sstv`: Start broadcasting an image converted to SSTV. For modes see [dnet/pySSTV](https://github.com/dnet/pySSTV/).
- Usage: `botwave> sstv <image path> [mode] [output wav name] [freq] [loop] [ps] [rt] [pi]`
Expand Down
20 changes: 19 additions & 1 deletion local/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from shared.logger import Log, toggle_input
from shared.morser import text_to_morse
from shared.pw_monitor import PWM
from shared.queue import Queue
from shared.sstv import make_sstv_wav
from shared.syscheck import check_requirements
from shared.ws_cmd import WSCMDH
Expand Down Expand Up @@ -60,6 +61,7 @@ def __init__(self, upload_dir: str = "/opt/BotWave/uploads", handlers_dir: str =
self.silent = not talk # if silent = True, piwave wont output any logs
self.piwave_monitor = PWM()
self.alsa = Alsa()
self.queue = Queue(client_instance=self, is_local=True, upload_dir=upload_dir)
self.ws_port = ws_port
self.ws_server = None
self.ws_clients = set()
Expand Down Expand Up @@ -134,9 +136,14 @@ def _execute_command(self, command: str):
elif cmd == 'stop':
self.stop_broadcast()
self.onstop_handlers()
self.queue.manual_pause()
Log.broadcast("Broadcast stopped")
return True

elif cmd == 'queue':
self.queue.parse(' '.join(cmd_parts[1:]))
return True

elif cmd == 'sstv':
if len(cmd_parts) < 2:
Log.error("Usage: sstv <image_path> [mode] [output_wav] [frequency] [loop] [ps] [rt] [pi]")
Expand Down Expand Up @@ -397,16 +404,20 @@ def _download_reporthook(block_num, block_size, total_size):
Log.error(f"Download error: {str(e)}")
return False

def start_broadcast(self, file_path: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False):
def start_broadcast(self, file_path: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False, trigger_manual: bool = True):
def finished():
Log.info("Playback finished, stopping broadcast...")
self.stop_broadcast()
self.onstop_handlers()
self.queue.on_broadcast_ended()

if not os.path.exists(file_path):
Log.error(f"File {file_path} not found")
return False

if trigger_manual:
self.queue.manual_pause()

if self.broadcasting:
self.stop_broadcast()

Expand Down Expand Up @@ -454,6 +465,8 @@ def finished():
Log.alsa("Did you setup the ALSA loopback card correctly ?")
return False

self.queue.manual_pause()

if self.broadcasting:
self.stop_broadcast()

Expand Down Expand Up @@ -588,6 +601,11 @@ def display_help(self):
Log.print(" live", "cyan")
Log.print("")

Log.print("queue [+|-|*|!|?]", "bright_green")
Log.print(" Manage broadcast queue", "white")
Log.print(" Use 'queue ?' for detailed help", "white")
Log.print("")

Log.print("sstv <image_path> [mode] [output_wav] [frequency] [loop] [ps] [rt] [pi]", "bright_green")
Log.print(" Convert an image into a SSTV WAV file, and then broadcast it", "white")
Log.print(" Example:", "white")
Expand Down
5 changes: 4 additions & 1 deletion server/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,12 @@ targets: Specifies the target clients. Can be 'all', a client ID, a hostname, or
`stop`: Stops broadcasting on specified client(s).
- Usage: `botwave> stop <targets>`

- `live`: Start a live broadcast to client(s).
`live`: Start a live broadcast to client(s).
- Usage: `botwave> live <all> [frequency] [loop] [ps] [rt] [pi]`

`queue`: Manages the queue. See the [`Main/Queue system`](https://github.com/dpipstudio/botwave/wiki/Queue-system) wiki page for more details.
- Usage: `botwave> queue ?`

`sstv`: Start broadcasting an image converted to SSTV. For modes see [dnet/pySSTV](https://github.com/dnet/pySSTV/).
- Usage: `botwave> sstv <targets> <image path> [mode] [output wav name] [freq] [loop] [ps] [rt] [pi]`

Expand Down
43 changes: 32 additions & 11 deletions server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from shared.logger import Log, toggle_input
from shared.morser import text_to_morse
from shared.protocol import ProtocolParser, Commands, PROTOCOL_VERSION
from shared.queue import Queue
from shared.socket import BWWebSocketServer
from shared.sstv import make_sstv_wav
from shared.tls import gen_cert, save_cert
Expand Down Expand Up @@ -81,9 +82,7 @@ def __init__(self, host: str = '0.0.0.0', ws_port: int = 9938, http_port: int =
self.running = False
self.pending_responses: Dict[str, asyncio.Future] = {}
self.file_list_responses: Dict[str, list] = {}

# cmd history for interactive mode
self.command_history = []
self.queue = Queue(self)

self.handlers_executor = HandlerExecutor(handlers_dir, self._execute_command)
self.loop = None
Expand Down Expand Up @@ -243,6 +242,16 @@ async def _handle_client_message(self, client_id: Optional[str], message: str, w

return

if command == Commands.END:
filename = kwargs.get('filename', 'unknown')
msg = kwargs.get('message')
if msg:
Log.error(f"{self.clients[client_id].get_display_name()}: {msg}")
else:
Log.broadcast(f"{self.clients[client_id].get_display_name()}: Finished broadcasting {filename}")
self.queue.on_broadcast_ended(client_id)
return

Log.warning(f"Unexpected command from {client_id}: {command}")

except Exception as e:
Expand Down Expand Up @@ -533,7 +542,7 @@ async def _execute_command_async(self, command_name: str, cmd: list):
if len(cmd) < 3:
Log.error("Usage: start <targets> <file> [freq] [loop] [ps] [rt] [pi]")
return

frequency = float(cmd[3]) if len(cmd) > 3 else 90.0
loop = cmd[4].lower() == 'true' if len(cmd) > 4 else False
ps = cmd[5] if len(cmd) > 5 else "BotWave"
Expand All @@ -547,7 +556,7 @@ async def _execute_command_async(self, command_name: str, cmd: list):
if len(cmd) < 2:
Log.error("Usage: live <targets> [freq] [ps] [rt] [pi]")
return

frequency = float(cmd[2]) if len(cmd) > 2 else 90.0
ps = cmd[3] if len(cmd) > 3 else "BotWave"
rt = cmd[4] if len(cmd) > 4 else "Broadcasting"
Expand All @@ -560,9 +569,16 @@ async def _execute_command_async(self, command_name: str, cmd: list):
if len(cmd) < 2:
Log.error("Usage: stop <targets>")
return

self.queue.toggle()

await self.stop_broadcast(cmd[1])
return

elif command_name == 'queue':
self.queue.parse(' '.join(cmd[1:]))
return

# OTHER MEDIA FORM
elif command_name == 'sstv':
if len(cmd) < 3:
Expand Down Expand Up @@ -830,6 +846,8 @@ async def start_live(self, client_targets: str, frequency: float = 90.0, ps: str
Log.alsa("Did you setup the ALSA loopback card correctly ?")
return False

self.queue.manual_pause()

self.alsa.start()

Log.broadcast(f"Sending stream tokens to {len(target_clients)} client(s)...")
Expand Down Expand Up @@ -1240,13 +1258,16 @@ def _remove_temp_dir(self, directory: str):
except Exception as e:
Log.warning(f"Failed to remove temp directory {directory}: {e}")

async def start_broadcast(self, client_targets: str, filename: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False):
async def start_broadcast(self, client_targets: str, filename: str, frequency: float = 90.0, ps: str = "BotWave", rt: str = "Broadcasting", pi: str = "FFFF", loop: bool = False, trigger_manual:bool = True):
target_clients = self._parse_client_targets(client_targets)

if not target_clients:
Log.warning("No client(s) found matching the query")
return False

if trigger_manual:
self.queue.manual_pause()

# calculate start_at timestamp if wait_start is enabled
if self.wait_start and len(target_clients) > 1:
start_at = datetime.now(timezone.utc).timestamp() + 20 * (len(target_clients) - 1)
Expand Down Expand Up @@ -1483,6 +1504,11 @@ def display_help(self):
Log.print(" stop all", "cyan")
Log.print("")

Log.print("queue [+|-|*|!|?]", "bright_green")
Log.print(" Manage broadcast queue", "white")
Log.print(" Use 'queue ?' for detailed help", "white")
Log.print("")

Log.print("live <targets> [freq] [ps] [rt] [pi]", "bright_green")
Log.print(" Start a live audio broadcast to client(s)", "white")
Log.print(" Example:", "white")
Expand Down Expand Up @@ -1654,11 +1680,6 @@ def run_async_server():

if HAS_READLINE:
readline.add_history(cmd_input)

server.command_history.append(cmd_input)

if len(server.command_history) > 1000:
server.command_history = server.command_history[-1000:]

server._execute_command(cmd_input)

Expand Down
6 changes: 4 additions & 2 deletions shared/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ class Logger(DLogger):
'auth': 'AUTH',
'tls': 'TLS',
'morse': 'MORSE',
'alsa': 'ALSA'
'alsa': 'ALSA',
'queue': 'QUEUE'
}

STYLES = {
Expand All @@ -46,7 +47,8 @@ class Logger(DLogger):
'auth': 'blue',
'tls': 'red',
'morse': 'purple',
'alsa': 'pink'
'alsa': 'pink',
'queue': 'orange'
}

ws_clients = set()
Expand Down
3 changes: 2 additions & 1 deletion shared/protocol.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import shlex
from typing import Dict, Tuple

PROTOCOL_VERSION = "2.0.1"
PROTOCOL_VERSION = "2.0.2"


class Commands:
Expand All @@ -18,6 +18,7 @@ class Commands:
# broadcast
START = 'START'
STOP = 'STOP'
END = 'END'

# files
UPLOAD_TOKEN = 'UPLOAD_TOKEN'
Expand Down
Loading