30 Commits
0.4 ... 0.5.2

Author SHA1 Message Date
df6c85d9ff Add new reset mechanism 2. 2026-04-10 11:58:16 +02:00
8106f61d6a Add new reset mechanism. 2026-04-10 10:57:12 +02:00
0a8dc74d5c Fixes script error in systemupdate. 2026-04-09 15:00:35 +02:00
8475e4d068 New system update logic. 2026-04-09 14:46:17 +02:00
3f01ef5968 Adds openocd with nrf support build to the server update function. Adds 2bad8ad2cd889d8c8d255b8e0dc0e7a187b98c9a hci_uart_beacon commit build hex file to project. (#26)
Co-authored-by: Pbopbo <p.obernesser@freenet.de>
Reviewed-on: #26
2026-04-09 12:04:18 +00:00
pober
67992e65ec Updates poetry lock. 2026-04-09 11:59:30 +02:00
0b12323921 fix/gain-4dbU (#25)
Co-authored-by: Pbopbo <p.obernesser@freenet.de>
Reviewed-on: #25
2026-04-09 09:54:14 +00:00
Pbopbo
6e633d2880 Merge branch 'wip_alsaaudio' TODO poetry lock 2026-04-09 11:51:37 +02:00
7bdf6f8417 feature/blue_led (#23)
Co-authored-by: pstruebi <office@summitwave.eu>
Co-authored-by: pober <paul.obernesser@summitwave.eu>
Co-authored-by: Pbopbo <p.obernesser@freenet.de>
Reviewed-on: #23
Co-authored-by: pstruebi <struebin.patrick@gmail.com>
Co-committed-by: pstruebi <struebin.patrick@gmail.com>
2026-04-07 14:34:11 +00:00
Pbopbo
291d75b137 stereo seems to work, NEEDS RADIO FIRMWARE WITH 2 TX BUFFERS. 2026-04-07 14:36:15 +02:00
Pbopbo
a126613739 First working version of two monos at the same time. 2026-04-02 18:56:17 +02:00
036b5f80dd Updates poetry lock. 2026-04-02 18:10:23 +02:00
Pbopbo
e818765b4f Adds sw_pyalsaaudio repo so our custom function works. 2026-04-02 17:37:38 +02:00
Pbopbo
3d59a6dabf ASRC: Adds NONBLOCK read from ALSA buffer; controls the amount of frames in the ALSA buffer; Adds resampling to get rid of audio glitches; no latency buildup anymore. 2026-04-01 14:00:26 +02:00
Pbopbo
cf69ad2957 134ms constant delay, no build up, seems to be no glitches, bang bang control. 2026-03-30 14:45:25 +02:00
Pbopbo
cdfecaf5eb delay method wip save to test no thread method. 2026-03-24 13:14:56 +01:00
4036fee1f5 Randomize Broadcast ID per stream instead of using static values 2026-03-24 12:09:16 +01:00
Pbopbo
1687a2b790 Latency lowered. 2026-03-18 17:37:34 +01:00
Pbopbo
a605195646 First good audio with alsaaudio. 2026-03-18 16:55:55 +01:00
pober
e1d717ed5c Adds DHCP/static IP toggle for both ports in the UI. 2026-03-03 15:50:19 +01:00
pober
540d8503ac Some corrections for Activates link local for both ports, removes fallback IP. 2026-03-03 15:35:13 +01:00
pober
c82f375539 Activates link local for both ports, removes fallback IP. 2026-03-03 15:02:55 +01:00
70bde5295f Fixes mDNS issue; when DHCP IP is present use this for mDNS and not the static fallback IP. 2026-02-16 16:25:59 +01:00
f5f93b4b8e analog_input_gain (#21)
- add input boost slider
- add level meter

Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/21
2026-02-12 17:09:46 +01:00
3322b9edf4 add 192.168.42.10 as default ip with update script 2026-02-12 17:08:23 +01:00
d6230e7522 add software gain boost parameter for input signal amplification 2026-02-12 13:30:07 +01:00
f2382470d8 add network information display showing hostname and IP address 2026-02-10 16:51:22 +01:00
7c2f0bf0cb add HTTP to HTTPS redirect server on port 80 2026-02-10 16:37:34 +01:00
184e9c84af impelement a gain slider 2026-01-20 18:00:37 +01:00
6852c74cd0 add a delete recordings button 2026-01-20 17:45:23 +01:00
19 changed files with 14209 additions and 169 deletions

3
.gitignore vendored
View File

@@ -51,4 +51,5 @@ src/auracast/available_samples.txt
src/auracast/server/stream_settings2.json
src/scripts/temperature_log*
src/auracast/server/recordings/
src/auracast/server/recordings/
src/auracast/server/led_settings.json

View File

@@ -4,8 +4,11 @@
- this projects uses poetry for package management
- if something should be run in a python env use 'poetry run'
# Environment
- this application normally runs on an embedded linux on a cm4
## Application
- this is a bluetooth Auracast transmitter application
- if you add a new parameter for a stream make sure it is saved to the settings.json so it is persisted
- it consists of multicast_frontend.py and multicast_server.py mainly which connect to each other via a rest api
- after you implemented something the user will mainly test it and you should call the update_and_run_server_and_frontend.sh script if the server and frontend were already running.

79
poetry.lock generated
View File

@@ -270,51 +270,6 @@ optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "av-14.4.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:10219620699a65b9829cfa08784da2ed38371f1a223ab8f3523f440a24c8381c"},
{file = "av-14.4.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:8bac981fde1c05e231df9f73a06ed9febce1f03fb0f1320707ac2861bba2567f"},
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc634ed5bdeb362f0523b73693b079b540418d35d7f3003654f788ae6c317eef"},
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23973ed5c5bec9565094d2b3643f10a6996707ddffa5252e112d578ad34aa9ae"},
{file = "av-14.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0655f7207db6a211d7cedb8ac6a2f7ccc9c4b62290130e393a3fd99425247311"},
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1edaab73319bfefe53ee09c4b1cf7b141ea7e6678a0a1c62f7bac1e2c68ec4e7"},
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54838fa17c031ffd780df07b9962fac1be05220f3c28468f7fe49474f1bf8d2"},
{file = "av-14.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f4b59ac6c563b9b6197299944145958a8ec34710799fd851f1a889b0cbcd1059"},
{file = "av-14.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a0192a584fae9f6cedfac03c06d5bf246517cdf00c8779bc33414404796a526e"},
{file = "av-14.4.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5b21d5586a88b9fce0ab78e26bd1c38f8642f8e2aad5b35e619f4d202217c701"},
{file = "av-14.4.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:cf8762d90b0f94a20c9f6e25a94f1757db5a256707964dfd0b1d4403e7a16835"},
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0ac9f08920c7bbe0795319689d901e27cb3d7870b9a0acae3f26fc9daa801a6"},
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a56d9ad2afdb638ec0404e962dc570960aae7e08ae331ad7ff70fbe99a6cf40e"},
{file = "av-14.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bed513cbcb3437d0ae47743edc1f5b4a113c0b66cdd4e1aafc533abf5b2fbf2"},
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d030c2d3647931e53d51f2f6e0fcf465263e7acf9ec6e4faa8dbfc77975318c3"},
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc21582a4f606271d8c2036ec7a6247df0831050306c55cf8a905701d0f0474"},
{file = "av-14.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce7c9cd452153d36f1b1478f904ed5f9ab191d76db873bdd3a597193290805d4"},
{file = "av-14.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd261e31cc6b43ca722f80656c39934199d8f2eb391e0147e704b6226acebc29"},
{file = "av-14.4.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a53e682b239dd23b4e3bc9568cfb1168fc629ab01925fdb2e7556eb426339e94"},
{file = "av-14.4.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:5aa0b901751a32703fa938d2155d56ce3faf3630e4a48d238b35d2f7e49e5395"},
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3b316fed3597675fe2aacfed34e25fc9d5bb0196dc8c0b014ae5ed4adda48de"},
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a587b5c5014c3c0e16143a0f8d99874e46b5d0c50db6111aa0b54206b5687c81"},
{file = "av-14.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d53f75e8ac1ec8877a551c0db32a83c0aaeae719d05285281eaaba211bbc30"},
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c8558cfde79dd8fc92d97c70e0f0fa8c94c7a66f68ae73afdf58598f0fe5e10d"},
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:455b6410dea0ab2d30234ffb28df7d62ca3cdf10708528e247bec3a4cdcced09"},
{file = "av-14.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1661efbe9d975f927b8512d654704223d936f39016fad2ddab00aee7c40f412c"},
{file = "av-14.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbbeef1f421a3461086853d6464ad5526b56ffe8ccb0ab3fd0a1f121dfbf26ad"},
{file = "av-14.4.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3d2aea7c602b105363903e4017103bc4b60336e7aff80e1c22e8b4ec09fd125f"},
{file = "av-14.4.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:38c18f036aeb6dc9abf5e867d998c867f9ec93a5f722b60721fdffc123bbb2ae"},
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58c1e18c8be73b6eada2d9ec397852ec74ebe51938451bdf83644a807189d6c8"},
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4c32ff03a357feb030634f093089a73cb474b04efe7fbfba31f229cb2fab115"},
{file = "av-14.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af31d16ae25964a6a02e09cc132b9decd5ee493c5dcb21bcdf0d71b2d6adbd59"},
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9fb297009e528f4851d25f3bb2781b2db18b59b10aed10240e947b77c582fb7"},
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:573314cb9eafec2827dc98c416c965330dc7508193adbccd281700d8673b9f0a"},
{file = "av-14.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f82ab27ee57c3b80eb50a5293222307dfdc02f810ea41119078cfc85ea3cf9a8"},
{file = "av-14.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f682003bbcaac620b52f68ff0e85830fff165dea53949e217483a615993ca20"},
{file = "av-14.4.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:8ff683777e0bb3601f7cfb4545dca25db92817585330b773e897e1f6f9d612f7"},
{file = "av-14.4.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:fe372acf7b1814bc2b16d89161609db63f81dad88684da76d26dd32cd1c16f92"},
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de869030eb8acfdfe39f39965de3a899dcde9b08df2db41f183c6166ca6f6d09"},
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9117ed91fba6299b7d5233dd3e471770bab829f97e5a157f182761e9fb59254c"},
{file = "av-14.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54e8f9209184098b7755e6250be8ffa48a8aa5b554a02555406120583da17373"},
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:38ea51e62a014663caec7f621d6601cf269ef450f3c8705f5e3225e5623fd15d"},
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d1d89842efe913448482573a253bd6955ce30a77f8a4cd04a1a3537cc919896"},
{file = "av-14.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c3048e333da1367a2bca47e69593e10bc70f027d876adee9d1582c8cb818f36a"},
{file = "av-14.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:5d6f25570d0782dd05640c7e1f71cb29857d94d915b5521a1e757ecae78a5a50"},
{file = "av-14.4.0.tar.gz", hash = "sha256:3ecbf803a7fdf67229c0edada0830d6bfaea4d10bfb24f0c3f4e607cd1064b42"},
]
@@ -1849,6 +1804,22 @@ files = [
{file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"},
]
[[package]]
name = "pyalsaaudio"
version = "0.11.0"
description = "ALSA bindings"
optional = false
python-versions = "*"
groups = ["main"]
files = []
develop = false
[package.source]
type = "git"
url = "ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git"
reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
resolved_reference = "b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
[[package]]
name = "pyarrow"
version = "20.0.0"
@@ -2443,6 +2414,22 @@ files = [
{file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"},
]
[[package]]
name = "rpi-gpio"
version = "0.7.1"
description = "A module to control Raspberry Pi GPIO channels"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "RPi.GPIO-0.7.1-cp27-cp27mu-linux_armv6l.whl", hash = "sha256:b86b66dc02faa5461b443a1e1f0c1d209d64ab5229696f32fb3b0215e0600c8c"},
{file = "RPi.GPIO-0.7.1-cp310-cp310-linux_armv6l.whl", hash = "sha256:57b6c044ef5375a78c8dda27cdfadf329e76aa6943cd6cffbbbd345a9adf9ca5"},
{file = "RPi.GPIO-0.7.1-cp37-cp37m-linux_armv6l.whl", hash = "sha256:77afb817b81331ce3049a4b8f94a85e41b7c404d8e56b61ac0f1eb75c3120868"},
{file = "RPi.GPIO-0.7.1-cp38-cp38-linux_armv6l.whl", hash = "sha256:29226823da8b5ccb9001d795a944f2e00924eeae583490f0bc7317581172c624"},
{file = "RPi.GPIO-0.7.1-cp39-cp39-linux_armv6l.whl", hash = "sha256:15311d3b063b71dee738cd26570effc9985a952454d162937c34e08c0fc99902"},
{file = "RPi.GPIO-0.7.1.tar.gz", hash = "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70"},
]
[[package]]
name = "samplerate"
version = "0.2.2"
@@ -2976,4 +2963,4 @@ test = ["pytest", "pytest-asyncio"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11"
content-hash = "3c9f92c7a5af40f98da9c7824d9c2a6f7eb809e91e43cfef4995761b2e887256"
content-hash = "7bccf2978170ead195e1e8cff151823a5276823195a239622186fcec830154d9"

View File

@@ -17,7 +17,9 @@ dependencies = [
"sounddevice (>=0.5.2,<0.6.0)",
"python-dotenv (>=1.1.1,<2.0.0)",
"smbus2 (>=0.5.0,<0.6.0)",
"samplerate (>=0.2.2,<0.3.0)"
"samplerate (>=0.2.2,<0.3.0)",
"rpi-gpio (>=0.7.1,<0.8.0)",
"pyalsaaudio @ git+ssh://git@gitea.summitwave.work:222/auracaster/sw_pyalsaaudio.git@b3d11582e03df6929b2e7acbaa1306afc7b8a6bc"
]
[project.optional-dependencies]

View File

@@ -111,3 +111,5 @@ class AuracastConfigGroup(AuracastGlobalConfig):
bigs: List[AuracastBigConfig] = [
AuracastBigConfigDeu(),
]
analog_gain_db_left: float = 0.0 # ADC gain level for analog mode left channel (-12 to 18 dB)
analog_gain_db_right: float = 0.0 # ADC gain level for analog mode right channel (-12 to 18 dB)

View File

@@ -30,6 +30,7 @@ import time
import threading
import numpy as np # for audio down-mix
import samplerate
import os
import lc3 # type: ignore # pylint: disable=E0401
@@ -56,7 +57,7 @@ from auracast.utils.webrtc_audio_input import WebRTCAudioInput
# Patch sounddevice.InputStream globally to use low-latency settings
import sounddevice as sd
import alsaaudio
from collections import deque
@@ -139,96 +140,146 @@ class AlsaArecordAudioInput(audio_io.AudioInput):
self._proc = None
class ModSoundDeviceAudioInput(audio_io.SoundDeviceAudioInput):
"""Patched SoundDeviceAudioInput with low-latency capture and adaptive resampling."""
class PyAlsaAudioInput(audio_io.ThreadedAudioInput):
"""PyALSA audio input with non-blocking reads - supports mono/stereo."""
def _open(self):
"""Create RawInputStream with low-latency parameters and initialize ring buffer."""
dev_info = sd.query_devices(self._device)
hostapis = sd.query_hostapis()
api_index = dev_info.get('hostapi')
api_name = hostapis[api_index]['name'] if isinstance(api_index, int) and 0 <= api_index < len(hostapis) else 'unknown'
pa_ver = sd.get_portaudio_version()
def __init__(self, device, pcm_format: audio_io.PcmFormat):
super().__init__()
logging.info("PyALSA: device = %s", device)
self._device = str(device) if not isinstance(device, str) else device
if self._device.isdigit():
self._device = 'default' if self._device == '0' else f'hw:{self._device}'
self._pcm_format = pcm_format
self._pcm = None
self._actual_channels = None
self._periodsize = None
self._hw_channels = None
self._first_read = True
self._resampler = None
self._resampler_buffer = np.empty(0, dtype=np.float32)
logging.info(
"SoundDevice backend=%s device='%s' (id=%s) ch=%s default_low_input_latency=%.4f default_high_input_latency=%.4f portaudio=%s",
api_name,
dev_info.get('name'),
self._device,
dev_info.get('max_input_channels'),
float(dev_info.get('default_low_input_latency') or 0.0),
float(dev_info.get('default_high_input_latency') or 0.0),
pa_ver[1] if isinstance(pa_ver, tuple) and len(pa_ver) >= 2 else pa_ver,
)
# Create RawInputStream with injected low-latency parameters
# Target ~2 ms blocksize (48 kHz -> 96 frames). For other rates, keep ~2 ms.
_sr = int(self._pcm_format.sample_rate)
self.counter=0
self.max_avail=0
self.logfile_name="available_samples.txt"
self.blocksize = 120
if os.path.exists(self.logfile_name):
os.remove(self.logfile_name)
self._stream = sd.RawInputStream(
samplerate=self._pcm_format.sample_rate,
def _open(self) -> audio_io.PcmFormat:
ALSA_PERIODSIZE = 240
ALSA_PERIODS = 4
ALSA_MODE = alsaaudio.PCM_NONBLOCK
requested_rate = int(self._pcm_format.sample_rate)
requested_channels = int(self._pcm_format.channels)
self._periodsize = ALSA_PERIODSIZE
self._pcm = alsaaudio.PCM(
type=alsaaudio.PCM_CAPTURE,
mode=ALSA_MODE,
device=self._device,
channels=self._pcm_format.channels,
dtype='int16',
blocksize=self.blocksize,
latency=0.004,
periods=ALSA_PERIODS,
)
self._stream.start()
self._pcm.setchannels(requested_channels)
self._pcm.setformat(alsaaudio.PCM_FORMAT_S16_LE)
actual_rate = self._pcm.setrate(requested_rate)
self._pcm.setperiodsize(ALSA_PERIODSIZE)
logging.info("PyALSA: device=%s rate=%d ch=%d periodsize=%d (%.1fms) periods=%d mode=%s",
self._device, actual_rate, requested_channels, ALSA_PERIODSIZE,
(ALSA_PERIODSIZE / actual_rate) * 1000, ALSA_PERIODS, ALSA_MODE)
if actual_rate != requested_rate:
logging.warning("PyALSA: Sample rate mismatch! requested=%d actual=%d", requested_rate, actual_rate)
self._actual_channels = requested_channels
self._resampler = samplerate.Resampler('sinc_fastest', channels=requested_channels)
self._resampler_buffer = np.empty(0, dtype=np.float32)
self._bang_bang = 0
return audio_io.PcmFormat(
audio_io.PcmFormat.Endianness.LITTLE,
audio_io.PcmFormat.SampleType.INT16,
self._pcm_format.sample_rate,
1,
actual_rate,
requested_channels,
)
def _read(self, frame_size: int) -> bytes:
"""Read PCM samples from the stream."""
try:
avail = self._pcm.avail()
logging.debug("PyALSA: avail before read: %d", avail)
length, data = self._pcm.read_sw(frame_size + self._bang_bang)
avail = self._pcm.avail()
SETPOINT = 120
TOLERANCE = 40
if avail < SETPOINT - TOLERANCE:
self._bang_bang = -1
elif avail > SETPOINT + TOLERANCE:
self._bang_bang = 1
else:
self._bang_bang = 0
logging.debug("PyALSA: read length=%d, data length=%d, avail=%d, bang_bang=%d", length, len(data), avail, self._bang_bang)
#if self.counter % 50 == 0:
frame_size = frame_size + 1 # consume samples a little faster to avoid latency akkumulation
if length > 0:
if self._first_read:
expected_mono = self._periodsize * 2
expected_stereo = self._periodsize * 2 * 2
# self._hw_channels = 2 if len(data) == expected_stereo else 1
self._hw_channels = self._actual_channels
logging.info("PyALSA first read: bytes=%d detected_hw_channels=%d requested_channels=%d",
len(data), self._hw_channels, self._actual_channels)
self._first_read = False
if self._hw_channels == 2 and self._actual_channels == 1:
pcm_stereo = np.frombuffer(data, dtype=np.int16)
pcm_mono = pcm_stereo[::2]
data = pcm_mono.tobytes()
actual_samples = len(data) // (2 * self._actual_channels)
ratio = frame_size / actual_samples
pcm_f32 = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
if self._actual_channels > 1:
pcm_f32 = pcm_f32.reshape(-1, self._actual_channels)
resampled = self._resampler.process(pcm_f32, ratio, end_of_input=False)
if self._actual_channels > 1:
resampled = resampled.reshape(-1)
self._resampler_buffer = np.concatenate([self._resampler_buffer, resampled])
else:
logging.warning("PyALSA: No data read from ALSA")
self._resampler_buffer = np.concatenate([
self._resampler_buffer,
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
])
except alsaaudio.ALSAAudioError as e:
logging.error("PyALSA: ALSA read error: %s", e)
self._resampler_buffer = np.concatenate([
self._resampler_buffer,
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
])
except Exception as e:
logging.error("PyALSA: Unexpected error in _read: %s", e, exc_info=True)
self._resampler_buffer = np.concatenate([
self._resampler_buffer,
np.zeros(frame_size * self._actual_channels, dtype=np.float32),
])
pcm_buffer, overflowed = self._stream.read(frame_size)
if overflowed:
logging.warning("SoundDeviceAudioInput: overflowed")
needed = frame_size * self._actual_channels
if len(self._resampler_buffer) < needed:
pad = np.zeros(needed - len(self._resampler_buffer), dtype=np.float32)
self._resampler_buffer = np.concatenate([self._resampler_buffer, pad])
logging.debug("PyALSA: padded buffer with %d samples", needed - len(self._resampler_buffer))
n_available = self._stream.read_available
output = self._resampler_buffer[:needed]
self._resampler_buffer = self._resampler_buffer[needed:]
# adapt = n_available > 20
# if adapt:
# pcm_extra, overflowed = self._stream.read(3)
# logging.info('consuming extra samples, available was %d', n_available)
# if overflowed:
# logging.warning("SoundDeviceAudioInput: overflowed")
# out = bytes(pcm_buffer) + bytes(pcm_extra)
# else:
out = bytes(pcm_buffer)
logging.debug("PyALSA: resampler_buffer remaining=%d", len(self._resampler_buffer))
return np.clip(output * 32767.0, -32768, 32767).astype(np.int16).tobytes()
self.max_avail = max(self.max_avail, n_available)
#Diagnostics
#with open(self.logfile_name, "a", encoding="utf-8") as f:
# f.write(f"{n_available}, {adapt}, {round(self._runavg, 2)}, {overflowed}\n")
def _close(self) -> None:
if self._pcm:
self._pcm.close()
self._pcm = None
if self.counter % 500 == 0:
logging.info(
"read available=%d, max=%d, latency:%d",
n_available, self.max_avail, self._stream.latency
)
self.max_avail = 0
self.counter += 1
return out
audio_io.SoundDeviceAudioInput = ModSoundDeviceAudioInput
audio_io.SoundDeviceAudioInput = PyAlsaAudioInput
# modified from bumble
class ModWaveAudioInput(audio_io.ThreadedAudioInput):
@@ -538,7 +589,7 @@ async def init_broadcast(
def on_flow():
data_packet_queue = iso_queue.data_packet_queue
print(
logging.info(
f'\rPACKETS: pending={data_packet_queue.pending}, '
f'queued={data_packet_queue.queued}, '
f'completed={data_packet_queue.completed}',
@@ -638,6 +689,12 @@ class Streamer():
except Exception:
pass
def get_audio_levels(self) -> list[float]:
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
if not self.bigs:
return []
return [big.get('_audio_level_rms', 0.0) for big in self.bigs.values()]
async def stream(self):
bigs = self.bigs
@@ -852,6 +909,11 @@ class Streamer():
stream_finished[i] = True
continue
# Compute RMS audio level (normalized 0.0-1.0) for level monitoring
pcm_samples = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32)
rms = np.sqrt(np.mean(pcm_samples ** 2)) / 32768.0 if len(pcm_samples) > 0 else 0.0
big['_audio_level_rms'] = float(rms)
# Measure LC3 encoding time
t1 = time.perf_counter()
num_bis = big.get('num_bis', 1)

View File

@@ -37,6 +37,12 @@ class Multicaster:
'is_initialized': self.is_auracast_init,
'is_streaming': streaming,
}
def get_audio_levels(self) -> list[float]:
"""Return current RMS audio levels (0.0-1.0) for each BIG."""
if self.streamer is not None and self.streamer.is_streaming:
return self.streamer.get_audio_levels()
return []
async def init_broadcast(self):
self.device_acm = multicast.create_device(self.global_conf)
@@ -137,6 +143,10 @@ async def main():
level=os.environ.get('LOG_LEVEL', logging.DEBUG),
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
# Enable debug logging for bumble
# logging.getLogger('bumble').setLevel(logging.DEBUG)
os.chdir(os.path.dirname(__file__))
global_conf = auracast_config.AuracastGlobalConfig(

View File

@@ -0,0 +1,42 @@
"""Minimal HTTP server that redirects all requests to HTTPS (port 443).
Run on port 80 alongside the HTTPS Streamlit frontend so that users who
type a bare IP address into their browser are automatically forwarded.
"""
import http.server
import sys
class RedirectHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
host = self.headers.get("Host", "").split(":")[0] or self.server.server_address[0]
target = f"https://{host}{self.path}"
self.send_response(301)
self.send_header("Location", target)
self.end_headers()
# Handle every method the same way
do_POST = do_GET
do_PUT = do_GET
do_DELETE = do_GET
do_HEAD = do_GET
def log_message(self, format, *args):
# Keep logging minimal
sys.stderr.write(f"[http-redirect] {self.address_string()} -> https {args[0] if args else ''}\n")
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 80
server = http.server.HTTPServer(("0.0.0.0", port), RedirectHandler)
print(f"HTTP->HTTPS redirect server listening on 0.0.0.0:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
server.server_close()
if __name__ == "__main__":
main()

View File

@@ -1,6 +1,7 @@
# frontend/app.py
import os
import time
import math
import logging as log
from PIL import Image
@@ -196,6 +197,116 @@ if audio_mode == "Demo":
else:
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming)
# Analog gain control (only for Analog mode, placed below start button)
analog_gain_db_left = 0 # default (dB)
analog_gain_db_right = 0 # default (dB)
if audio_mode == "Analog":
if '_analog_gain_db_left' not in st.session_state:
st.session_state['_analog_gain_db_left'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_left', 0))))
if '_analog_gain_db_right' not in st.session_state:
st.session_state['_analog_gain_db_right'] = max(-12, min(18, int(saved_settings.get('analog_gain_db_right', 0))))
if '_gain_link_channels' not in st.session_state:
st.session_state['_gain_link_channels'] = True
link_channels = st.checkbox(
"Link audio channel gain",
key='_gain_link_channels',
help="When enabled, Ch 2 mirrors Ch 1."
)
_gain_col1, _gain_col2 = st.columns(2)
with _gain_col1:
analog_gain_db_left = st.slider(
"Ch 1 Input Gain",
min_value=-12,
max_value=18,
key='_analog_gain_db_left',
step=1,
format="%d dB",
help="ADC gain for channel 1 (-12 to 18 dB). Default is 0 dB."
)
with _gain_col2:
if link_channels:
st.session_state['_analog_gain_db_right'] = analog_gain_db_left
elif st.session_state.get('_prev_gain_link_channels', True):
# Transition: just unlinked — seed Ch 2 from Ch 1 so it doesn't jump to min
st.session_state['_analog_gain_db_right'] = analog_gain_db_left
st.session_state['_prev_gain_link_channels'] = link_channels
analog_gain_db_right = st.slider(
"Ch 2 Input Gain",
min_value=-12,
max_value=18,
key='_analog_gain_db_right',
step=1,
format="%d dB",
disabled=link_channels,
help="Uncheck 'Link audio channel gain' to adjust Ch 2 independently." if link_channels else "ADC gain for channel 2 (-12 to 18 dB). Default is 0 dB."
)
# Apply gain live while streaming whenever either slider value changes
if is_streaming:
prev_left = st.session_state.get('_prev_analog_gain_db_left')
prev_right = st.session_state.get('_prev_analog_gain_db_right')
if prev_left != analog_gain_db_left or prev_right != analog_gain_db_right:
try:
requests.post(
f"{BACKEND_URL}/adc_gain",
json={"gain_db_left": analog_gain_db_left, "gain_db_right": analog_gain_db_right},
timeout=1,
)
except Exception:
pass
st.session_state['_prev_analog_gain_db_left'] = analog_gain_db_left
st.session_state['_prev_analog_gain_db_right'] = analog_gain_db_right
# Audio level monitor (checkbox, not persisted across reloads)
show_level_monitor = st.checkbox("Audio level monitor", value=False, disabled=not is_streaming,
help="Show real-time audio level meters for active radios. Only works while streaming.")
if show_level_monitor and is_streaming:
@st.fragment(run_every=0.2)
def _audio_level_fragment():
cols = st.columns(2)
# Radio 1
with cols[0]:
try:
r = requests.get(f"{BACKEND_URL}/audio_level", timeout=0.2)
levels = r.json().get("levels", []) if r.ok else []
except Exception:
levels = []
if levels:
rms = max(levels)
db = max(-60.0, 20.0 * (math.log10(rms) if rms > 0 else -3.0))
pct = int(max(0, min(100, (db + 60) * 100 / 60)))
st.markdown(
f"**Radio 1**"
f'<div style="background:#333;border-radius:4px;height:18px;width:100%;margin-top:4px;">'
f'<div style="background:#2ecc71;height:100%;width:{pct}%;border-radius:4px;transition:width 0.15s;"></div>'
f'</div>',
unsafe_allow_html=True,
)
else:
st.markdown("**Radio 1** &nbsp; --")
# Radio 2
with cols[1]:
try:
r2 = requests.get(f"{BACKEND_URL}/audio_level2", timeout=0.2)
levels2 = r2.json().get("levels", []) if r2.ok else []
except Exception:
levels2 = []
if levels2:
rms2 = max(levels2)
db2 = max(-60.0, 20.0 * (math.log10(rms2) if rms2 > 0 else -3.0))
pct2 = int(max(0, min(100, (db2 + 60) * 100 / 60)))
st.markdown(
f"**Radio 2**"
f'<div style="background:#333;border-radius:4px;height:18px;width:100%;margin-top:4px;">'
f'<div style="background:#2ecc71;height:100%;width:{pct2}%;border-radius:4px;transition:width 0.15s;"></div>'
f'</div>',
unsafe_allow_html=True,
)
else:
st.markdown("**Radio 2** &nbsp; --")
_audio_level_fragment()
# Placeholder for validation errors (will be filled in later)
validation_error_placeholder = st.empty()
@@ -602,6 +713,8 @@ else:
'immediate_rendering': immediate_rendering2,
'presentation_delay_ms': presentation_delay_ms2,
'qos_preset': qos_preset2,
'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right,
}
radio1_cfg = {
@@ -616,7 +729,9 @@ else:
'immediate_rendering': immediate_rendering1,
'presentation_delay_ms': presentation_delay_ms1,
'qos_preset': qos_preset1,
'stereo_mode': stereo_enabled, # Add stereo mode setting
'stereo_mode': stereo_enabled,
'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right,
}
if audio_mode == "Network - Dante":
@@ -1499,9 +1614,10 @@ if start_stream:
immediate_rendering=bool(cfg['immediate_rendering']),
presentation_delay_us=int(cfg['presentation_delay_ms'] * 1000),
qos_config=QOS_PRESET_MAP[cfg['qos_preset']],
analog_gain_db_left=cfg.get('analog_gain_db_left', 0.0),
analog_gain_db_right=cfg.get('analog_gain_db_right', 0.0),
bigs=[
auracast_config.AuracastBigConfig(
id=cfg.get('id', 123456),
code=(cfg['stream_passwort'].strip() or None),
name=cfg['name'],
program_info=cfg['program_info'],
@@ -1555,8 +1671,6 @@ if start_stream:
if not stream.get('input_device'):
continue
stream_id = radio_id * 1000 + i + 1 # Unique ID per stream
# Check if this specific stream uses stereo (dante_stereo_X_Y device)
input_device = stream['input_device']
stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_')
@@ -1564,7 +1678,6 @@ if start_stream:
num_channels = 2 if stream_is_stereo else 1
bigs.append(auracast_config.AuracastBigConfig(
id=stream_id,
code=(stream.get('stream_password', '').strip() or None),
name=stream['name'],
program_info=stream['program_info'],
@@ -1667,7 +1780,21 @@ if is_started or is_stopped:
# System expander (collapsed)
############################
with st.expander("System control", expanded=False):
st.subheader("Status LED")
led_enabled_current = bool(saved_settings.get("led_enabled", True))
led_enabled = st.checkbox(
"Blue LED on while transmitting",
value=led_enabled_current,
help="When enabled, the blue LED on GPIO pin 12 lights up while the stream is active."
)
if led_enabled != led_enabled_current:
try:
requests.post(f"{BACKEND_URL}/set_led_enabled", json={"led_enabled": led_enabled}, timeout=2)
except Exception as e:
st.error(f"Failed to update LED setting: {e}")
st.rerun()
st.subheader("System temperatures")
temp_col1, temp_col2, temp_col3 = st.columns([1, 1, 1])
with temp_col1:
@@ -1682,6 +1809,123 @@ with st.expander("System control", expanded=False):
except Exception as e:
st.warning(f"Could not read temperatures: {e}")
st.subheader("Network Information")
try:
import subprocess, socket
device_hostname = socket.gethostname()
st.write(f"Hostname: **{device_hostname}**")
network_info_resp = requests.get(f"{BACKEND_URL}/network_info", timeout=5)
if network_info_resp.status_code == 200:
network_data = network_info_resp.json()
interfaces = network_data.get("interfaces", {})
port_mapping = network_data.get("port_mapping", {})
for port_name in ["port1", "port2"]:
if port_name not in port_mapping:
continue
interface_name = port_mapping[port_name]
interface_data = interfaces.get(interface_name, {})
port_label = "Port 1" if port_name == "port1" else "Port 2"
st.markdown(f"### {port_label}")
ip_address = interface_data.get("ip_address", "N/A")
is_dhcp = interface_data.get("is_dhcp", True)
st.write(f"Interface: **{interface_name}**")
st.write(f"IP Address: **{ip_address}**")
col1, col2 = st.columns([1, 3])
with col1:
toggle_key = f"{port_name}_dhcp_toggle"
current_mode = "DHCP" if is_dhcp else "Static IP"
new_mode = st.radio(
"Mode",
options=["DHCP", "Static IP"],
index=0 if is_dhcp else 1,
key=toggle_key,
horizontal=True
)
with col2:
if new_mode == "Static IP":
ip_input_key = f"{port_name}_ip_input"
default_ip = ip_address if ip_address != "N/A" and not is_dhcp else ""
new_ip = st.text_input(
"Static IP Address",
value=default_ip,
key=ip_input_key,
placeholder="192.168.1.100"
)
if st.button(f"Apply", key=f"{port_name}_apply_btn"):
if not new_ip:
st.error("Please enter an IP address")
else:
import re
ip_pattern = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
if not ip_pattern.match(new_ip):
st.error("Invalid IP address format")
else:
octets = new_ip.split('.')
if not all(0 <= int(octet) <= 255 for octet in octets):
st.error("IP address octets must be between 0 and 255")
else:
try:
config_payload = {
"interface": interface_name,
"is_dhcp": False,
"ip_address": new_ip,
"netmask": "24"
}
config_resp = requests.post(
f"{BACKEND_URL}/set_network_config",
json=config_payload,
timeout=10
)
if config_resp.status_code == 200:
st.success(f"Static IP {new_ip} applied to {interface_name}")
time.sleep(2)
st.rerun()
else:
st.error(f"Failed to apply configuration: {config_resp.text}")
except Exception as e:
st.error(f"Error applying configuration: {e}")
else:
if new_mode != current_mode:
if st.button(f"Apply DHCP", key=f"{port_name}_dhcp_apply_btn"):
try:
config_payload = {
"interface": interface_name,
"is_dhcp": True
}
config_resp = requests.post(
f"{BACKEND_URL}/set_network_config",
json=config_payload,
timeout=10
)
if config_resp.status_code == 200:
st.success(f"DHCP enabled for {interface_name}")
time.sleep(2)
st.rerun()
else:
st.error(f"Failed to apply configuration: {config_resp.text}")
except Exception as e:
st.error(f"Error applying configuration: {e}")
st.markdown("---")
else:
result = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=2)
ips = [ip for ip in result.stdout.strip().split() if not ip.startswith('127.') and ':' not in ip]
if ips:
st.write(f"IP Address: **{ips[0]}**")
else:
st.warning("No valid IP address found.")
except Exception as e:
st.warning(f"Could not determine network info: {e}")
st.subheader("CA Certificate")
st.caption("Download the CA certificate to trust this device's HTTPS connection.")
try:
@@ -1842,7 +2086,7 @@ with st.expander("Record", expanded=False):
selected_device_name = None
# Recording controls
col_record, col_download = st.columns([1, 1])
col_record, col_download, col_delete = st.columns([1, 1, 1])
with col_record:
if st.button("Start Recording (5s)", disabled=not selected_device_name):
@@ -1879,6 +2123,22 @@ with st.expander("Record", expanded=False):
st.warning(f"Could not fetch recording: {e}")
else:
st.button("Download Last Recording", disabled=True, help="No recording available yet")
with col_delete:
if st.button("Delete Recordings", type="secondary"):
try:
r = requests.delete(f"{BACKEND_URL}/delete_recordings", timeout=10)
if r.ok:
result = r.json()
if result.get('success'):
st.success(f"Deleted {result.get('deleted_count', 0)} recording(s)")
st.session_state['last_recording'] = None
else:
st.error("Failed to delete recordings")
else:
st.error(f"Failed to delete recordings: {r.status_code}")
except Exception as e:
st.error(f"Error deleting recordings: {e}")
log.basicConfig(
level=os.environ.get('LOG_LEVEL', log.DEBUG),

View File

@@ -3,6 +3,7 @@ TODO: in the future the multicaster objects should run in their own threads or e
"""
import os
import re
import logging as log
import json
from datetime import datetime
@@ -26,6 +27,57 @@ from auracast.utils.sounddevice_utils import (
)
load_dotenv()
# Blue LED on GPIO pin 12 (BCM) turns on while transmitting
LED_PIN = 12
try:
import RPi.GPIO as _GPIO
_GPIO.setmode(_GPIO.BCM)
_GPIO.setup(LED_PIN, _GPIO.OUT)
_GPIO_AVAILABLE = True
except Exception:
_GPIO_AVAILABLE = False
_GPIO = None # type: ignore
_LED_ENABLED: bool = True # toggled via /set_led_enabled
_LED_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'led_settings.json')
def _load_led_settings() -> None:
global _LED_ENABLED
try:
if os.path.exists(_LED_SETTINGS_FILE):
with open(_LED_SETTINGS_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
_LED_ENABLED = bool(data.get('led_enabled', True))
except Exception:
_LED_ENABLED = True
def _save_led_settings() -> None:
try:
os.makedirs(os.path.dirname(_LED_SETTINGS_FILE), exist_ok=True)
with open(_LED_SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump({'led_enabled': _LED_ENABLED}, f)
except Exception:
pass
def _led_on():
if _GPIO_AVAILABLE and _LED_ENABLED:
try:
_GPIO.output(LED_PIN, _GPIO.LOW)
except Exception:
pass
def _led_off():
if _GPIO_AVAILABLE:
try:
_GPIO.output(LED_PIN, _GPIO.HIGH)
except Exception:
pass
# Configure bumble debug logging
# log.getLogger('bumble').setLevel(log.DEBUG)
# make sure pipewire sets latency
# Primary and secondary persisted settings files
STREAM_SETTINGS_FILE1 = os.path.join(os.path.dirname(__file__), 'stream_settings.json')
@@ -133,6 +185,10 @@ def save_settings(persisted: dict, secondary: bool = False) -> None:
def gen_random_add() -> str:
return ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
def gen_random_broadcast_id() -> int:
"""Generate a random 24-bit Broadcast ID (1..0xFFFFFF)."""
return random.randint(1, 0xFFFFFF)
app = FastAPI()
# Allow CORS for frontend on localhost
@@ -238,12 +294,18 @@ async def _init_i2c_on_startup() -> None:
log.warning("Exception running i2cget (%s): %s", " ".join(read_cmd), e, exc_info=True)
async def _set_adc_level_on_startup() -> None:
"""Ensure ADC mixer level is set at startup.
async def _set_adc_level(gain_db_left: float = 0.0, gain_db_right: float = 0.0) -> None:
"""Set ADC mixer gain in dB for left and right channels independently.
Runs: amixer -c 2 set 'ADC' x%
Runs: amixer -c 2 sset ADC {gain_db_left}dB,{gain_db_right}dB
Args:
gain_db_left: Left channel gain in dB (-12 to 18), default 0
gain_db_right: Right channel gain in dB (-12 to 18), default 0
"""
cmd = ["amixer", "-c", "2", "set", "ADC", "80%"]
gain_db_left = max(-12.0, min(18.0, gain_db_left))
gain_db_right = max(-12.0, min(18.0, gain_db_right))
cmd = ["amixer", "-c", "2", "sset", "ADC", "--", f"{int(gain_db_left)}dB,{int(gain_db_right)}dB"]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
@@ -252,15 +314,46 @@ async def _set_adc_level_on_startup() -> None:
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
log.warning(
log.error(
"amixer ADC level command failed (rc=%s): %s",
proc.returncode,
(stderr or b"" ).decode(errors="ignore").strip(),
)
else:
log.info("amixer ADC level set successfully: %s", (stdout or b"" ).decode(errors="ignore").strip())
read_proc = await asyncio.create_subprocess_exec(
"amixer", "-c", "2", "sget", "ADC",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
read_stdout, read_stderr = await read_proc.communicate()
if read_proc.returncode != 0:
log.error(
"amixer ADC sget failed (rc=%s): %s",
read_proc.returncode,
(read_stderr or b"").decode(errors="ignore").strip(),
)
else:
sget_output = (read_stdout or b"").decode(errors="ignore")
actual = {}
for line in sget_output.splitlines():
for ch_key, ch_name in (("left", "Front Left"), ("right", "Front Right")):
if ch_name in line:
m = re.search(r'\[(-?\d+(?:\.\d+)?)dB\]', line)
if m:
actual[ch_key] = round(float(m.group(1)))
expected_left = int(gain_db_left)
expected_right = int(gain_db_right)
if actual.get("left") != expected_left or actual.get("right") != expected_right:
mismatch = (
f"ADC level mismatch after set: expected L={expected_left}dB R={expected_right}dB, "
f"got L={actual.get('left')}dB R={actual.get('right')}dB"
)
log.error(mismatch)
else:
log.info("ADC level set successfully: L=%sdB R=%sdB", expected_left, expected_right)
except Exception as e:
log.warning("Exception running amixer ADC level command: %s", e, exc_info=True)
log.error("Exception running amixer ADC level command: %s", e, exc_info=True)
async def _stop_all() -> bool:
@@ -280,6 +373,7 @@ async def _stop_all() -> bool:
was_running = True
finally:
multicaster2 = None
_led_off()
return was_running
async def _status_primary() -> dict:
@@ -338,6 +432,10 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
if input_device_name in ('ch1', 'ch2'):
audio_mode_persist = 'Analog'
# Set ADC gain level for analog mode
analog_gain_db_left = getattr(conf, 'analog_gain_db_left', 0.0)
analog_gain_db_right = getattr(conf, 'analog_gain_db_right', 0.0)
await _set_adc_level(analog_gain_db_left, analog_gain_db_right)
elif input_device_name in dante_channels:
audio_mode_persist = 'Network - Dante'
else:
@@ -380,18 +478,15 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
if is_stereo and sel == 'ch1':
# Stereo mode: use ALSA directly to capture both channels from hardware
# ch1=left (channel 0), ch2=right (channel 1)
big.audio_source = 'alsa:hw:CARD=i2s,DEV=0'
big.audio_source = 'device:hw:2'
big.input_format = f"int16le,{hardware_capture_rate},2"
log.info("Configured analog stereo input: using ALSA hw:CARD=i2s,DEV=0 with ch1=left, ch2=right")
elif is_stereo and sel == 'ch2':
# Skip ch2 in stereo mode as it's already captured as part of stereo pair
continue
else:
# Mono mode: individual channel capture
device_index = resolve_input_device_index(sel)
if device_index is None:
raise HTTPException(status_code=400, detail=f"Audio device '{sel}' not found.")
big.audio_source = f'device:{device_index}'
# Mono mode: use dsnoop virtual device directly (ch1=left, ch2=right)
big.audio_source = f'device:{sel}'
big.input_format = f"int16le,{hardware_capture_rate},1"
continue
@@ -447,10 +542,12 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
conf.qos_config.max_transport_latency_ms = int(conf.qos_config.number_of_retransmissions) * 10 + 3
# Only generate a new random_address if the BIG is still at the model default.
# Generate fresh random_address and broadcast ID for any BIG still at model defaults.
for big in conf.bigs:
if not getattr(big, 'random_address', None) or big.random_address == DEFAULT_RANDOM_ADDRESS:
big.random_address = gen_random_add()
if big.id == DEFAULT_BIG_ID:
big.id = gen_random_broadcast_id()
# Log the final, fully-updated configuration just before creating the Multicaster
log.info('Final multicaster config (transport=%s):\n %s', transport, conf.model_dump_json(indent=2))
@@ -461,6 +558,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
auto_started = False
if any(isinstance(big.audio_source, str) and (big.audio_source.startswith("device:") or big.audio_source.startswith("alsa:") or big.audio_source.startswith("file:")) for big in conf.bigs):
await mc.start_streaming()
_led_on()
auto_started = True
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
@@ -485,6 +583,8 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'immediate_rendering': getattr(conf, 'immediate_rendering', False),
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', False),
'analog_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
'analog_gain_db_left': getattr(conf, 'analog_gain_db_left', 0.0),
'analog_gain_db_right': getattr(conf, 'analog_gain_db_right', 0.0),
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
'big_ids': [getattr(big, 'id', DEFAULT_BIG_ID) for big in conf.bigs],
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
@@ -519,6 +619,16 @@ async def initialize2(conf: auracast_config.AuracastConfigGroup):
multicaster2 = mc
save_settings(persisted, secondary=True)
@app.post("/set_led_enabled")
async def set_led_enabled(body: dict):
"""Enable or disable the blue status LED. Persisted across restarts."""
global _LED_ENABLED
_LED_ENABLED = bool(body.get("led_enabled", True))
_save_led_settings()
if not _LED_ENABLED:
_led_off()
return {"led_enabled": _LED_ENABLED}
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming on both multicaster1 and multicaster2 (worker thread)."""
@@ -547,6 +657,28 @@ async def stop_audio():
log.error("Exception in /stop_audio: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/adc_gain")
async def set_adc_gain(payload: dict):
"""Set ADC gain in dB for left and right channels without restarting the stream.
Body: {"gain_db_left": float, "gain_db_right": float}
"""
try:
gain_db_left = float(payload.get("gain_db_left", 0.0))
gain_db_right = float(payload.get("gain_db_right", 0.0))
await _set_adc_level(gain_db_left, gain_db_right)
# Persist the new values so they survive a restart
for load_fn, save_fn in [(load_stream_settings, save_stream_settings), (load_stream_settings2, save_stream_settings2)]:
s = load_fn() or {}
if s:
s['analog_gain_db_left'] = gain_db_left
s['analog_gain_db_right'] = gain_db_right
save_fn(s)
return {"status": "ok", "gain_db_left": gain_db_left, "gain_db_right": gain_db_right}
except Exception as e:
log.error("Exception in /adc_gain: %s", traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Sends a block of pre-coded LC3 audio via the worker."""
@@ -582,9 +714,24 @@ async def get_status():
secondary.update(secondary_persisted)
status["secondary"] = secondary
status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False))
status["led_enabled"] = _LED_ENABLED
return status
@app.get("/audio_level")
async def get_audio_level():
"""Return current RMS audio levels for primary radio (lightweight, for polling)."""
if multicaster1 is None:
return {"levels": []}
return {"levels": multicaster1.get_audio_levels()}
@app.get("/audio_level2")
async def get_audio_level2():
"""Return current RMS audio levels for secondary radio (lightweight, for polling)."""
if multicaster2 is None:
return {"levels": []}
return {"levels": multicaster2.get_audio_levels()}
async def _autostart_from_settings():
settings1 = load_stream_settings() or {}
settings2 = load_stream_settings2() or {}
@@ -737,6 +884,8 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
bigs=bigs,
)
# Set num_bis for stereo mode if needed
@@ -890,6 +1039,8 @@ async def _autostart_from_settings():
immediate_rendering=immediate_rendering,
assisted_listening_stream=assisted_listening_stream,
presentation_delay_us=pres_delay if pres_delay is not None else 40000,
analog_gain_db_left=settings.get('analog_gain_db_left', 0.0),
analog_gain_db_right=settings.get('analog_gain_db_right', 0.0),
bigs=bigs,
)
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -911,9 +1062,10 @@ async def _autostart_from_settings():
async def _startup_autostart_event():
# Spawn the autostart task without blocking startup
log.info("[STARTUP] Auracast multicast server startup: initializing settings cache, I2C, and PipeWire cache")
_led_off()
# Run install_asoundconf.sh script
script_path = os.path.join(os.path.dirname(__file__), '..', 'misc', 'install_asoundconf.sh')
script_path = os.path.join(os.path.dirname(__file__), '..', '..', 'misc', 'install_asoundconf.sh')
try:
log.info("[STARTUP] Running install_asoundconf.sh script")
result = subprocess.run(['bash', script_path], capture_output=True, text=True, check=True)
@@ -924,10 +1076,11 @@ async def _startup_autostart_event():
log.error(f"[STARTUP] Error running install_asoundconf.sh: {str(e)}")
# Hydrate settings cache once to avoid disk I/O during /status
_load_led_settings()
_init_settings_cache_from_disk()
await _init_i2c_on_startup()
# Ensure ADC mixer level is set at startup
await _set_adc_level_on_startup()
# Ensure ADC mixer level is set at startup (default 0 dB)
await _set_adc_level(0.0, 0.0)
refresh_pw_cache()
log.info("[STARTUP] Scheduling autostart task")
asyncio.create_task(_autostart_from_settings())
@@ -1218,26 +1371,12 @@ async def system_update():
log.error("git checkout failed: %s", stderr.decode())
raise HTTPException(status_code=500, detail=f"git checkout failed: {stderr.decode()}")
# 2. Run poetry install (use full path as poetry is in user's ~/.local/bin)
poetry_path = os.path.expanduser("~/.local/bin/poetry")
proc = await asyncio.create_subprocess_exec(
poetry_path, "install",
cwd=project_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
log.error("poetry install failed: %s", stderr.decode())
raise HTTPException(status_code=500, detail=f"poetry install failed: {stderr.decode()}")
# 3. Restart services via the update script
update_script = os.path.join(project_root, 'src', 'service', 'update_and_run_server_and_frontend.sh')
proc = await asyncio.create_subprocess_exec(
# 2. Hand off remaining work to the (now-updated) system_update.sh script
update_script = os.path.join(os.path.dirname(__file__), 'system_update.sh')
log.info("Handing off to system_update.sh...")
await asyncio.create_subprocess_exec(
"bash", update_script,
cwd=project_root,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Don't wait for completion as we'll be restarted
await asyncio.sleep(0.5)
@@ -1413,6 +1552,235 @@ async def download_recording(filename: str):
raise HTTPException(status_code=500, detail=str(e))
@app.delete("/delete_recordings")
async def delete_recordings():
"""Delete all recordings in the recordings folder."""
try:
deleted_count = 0
for filename in os.listdir(RECORDINGS_DIR):
filepath = os.path.join(RECORDINGS_DIR, filename)
if os.path.isfile(filepath):
try:
os.remove(filepath)
deleted_count += 1
log.info("Deleted recording: %s", filename)
except Exception as e:
log.warning("Failed to delete %s: %s", filename, e)
log.info("Deleted %d recordings", deleted_count)
return {"success": True, "deleted_count": deleted_count}
except Exception as e:
log.error("Exception in /delete_recordings: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/network_info")
async def get_network_info():
"""Get network information for all ethernet interfaces."""
try:
interfaces = {}
hardcoded_devices = ["eth0", "eth1"]
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
device_to_connection = {}
if proc.returncode == 0:
for line in stdout.decode().strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) >= 2:
connection_name = parts[0]
device_name = parts[1]
if device_name in hardcoded_devices:
device_to_connection[device_name] = connection_name
for device in hardcoded_devices:
ip_address = None
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", device,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
for line in stdout.decode().strip().split('\n'):
if line.startswith('IP4.ADDRESS'):
ip_parts = line.split(':')
if len(ip_parts) >= 2:
full_ip = ip_parts[1]
ip_address = full_ip.split('/')[0]
if not ip_address.startswith('169.254.'):
break
method = "auto"
connection_name = device_to_connection.get(device)
if connection_name:
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "ipv4.method", "connection", "show", connection_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
for line in stdout.decode().strip().split('\n'):
if line.startswith('ipv4.method:'):
method = line.split(':')[1]
break
is_dhcp = method == "auto"
interfaces[device] = {
"ip_address": ip_address or "N/A",
"is_dhcp": is_dhcp,
"method": method,
"connection_name": connection_name
}
port_mapping = {
"port1": "eth0",
"port2": "eth1"
}
return {
"interfaces": interfaces,
"port_mapping": port_mapping
}
except HTTPException:
raise
except Exception as e:
log.error("Exception in /network_info: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.post("/set_network_config")
async def set_network_config(config: dict):
"""Set network configuration (DHCP or Static IP) for a specific interface.
Expected payload:
{
"interface": "eth0",
"is_dhcp": true/false,
"ip_address": "192.168.1.100" (required if is_dhcp is false),
"netmask": "24" (optional, defaults to 24)
}
"""
try:
interface = config.get("interface")
is_dhcp = config.get("is_dhcp", True)
ip_address = config.get("ip_address")
netmask = config.get("netmask", "24")
if not interface:
raise HTTPException(status_code=400, detail="Interface name is required")
proc = await asyncio.create_subprocess_exec(
"nmcli", "-t", "-f", "NAME,DEVICE", "connection", "show",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to get network connections")
connection_name = None
for line in stdout.decode().strip().split('\n'):
if not line:
continue
parts = line.split(':')
if len(parts) >= 2 and parts[1] == interface:
connection_name = parts[0]
break
if not connection_name:
log.info(f"No connection found for {interface}, creating new connection")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "add", "type", "ethernet",
"ifname", interface, "con-name", f"Wired connection {interface}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=f"Failed to create connection for {interface}: {stderr.decode()}")
connection_name = f"Wired connection {interface}"
if is_dhcp:
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "modify", connection_name, "ipv4.method", "auto",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to set DHCP mode")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "modify", connection_name, "ipv4.addresses", "",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
else:
if not ip_address:
raise HTTPException(status_code=400, detail="IP address is required for static configuration")
import re
ip_pattern = re.compile(r'^(\d{1,3}\.){3}\d{1,3}$')
if not ip_pattern.match(ip_address):
raise HTTPException(status_code=400, detail="Invalid IP address format")
octets = ip_address.split('.')
if not all(0 <= int(octet) <= 255 for octet in octets):
raise HTTPException(status_code=400, detail="IP address octets must be between 0 and 255")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "modify", connection_name,
"ipv4.method", "manual",
"ipv4.addresses", f"{ip_address}/{netmask}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail="Failed to set static IP")
proc = await asyncio.create_subprocess_exec(
"sudo", "nmcli", "con", "up", connection_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
log.info("Connection activation returned non-zero (may be expected if no cable): %s", stderr.decode())
return {"status": "success", "message": f"Network configuration updated for {interface}"}
except HTTPException:
raise
except Exception as e:
log.error("Exception in /set_network_config: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__':
import os
os.chdir(os.path.dirname(__file__))

View File

@@ -33,5 +33,12 @@ echo "Using Avahi domain: $AVAHI_DOMAIN"
# Path to poetry binary
POETRY_BIN="/home/caster/.local/bin/poetry"
# Start HTTP->HTTPS redirect server on port 80 (background)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
python3 "$SCRIPT_DIR/http_to_https_redirect.py" 80 &
REDIRECT_PID=$!
echo "HTTP->HTTPS redirect server started (PID $REDIRECT_PID)"
trap "kill $REDIRECT_PID 2>/dev/null" EXIT
# Start Streamlit HTTPS server (port 443)
$POETRY_BIN run streamlit run multicast_frontend.py --server.port 443 --server.address 0.0.0.0 --server.enableCORS false --server.enableXsrfProtection false --server.headless true --server.sslCertFile "$CERT" --server.sslKeyFile "$KEY" --browser.gatherUsageStats false

View File

@@ -0,0 +1,90 @@
#!/bin/bash
# system_update.sh - Runs after git checkout in the Python system_update endpoint.
# Called with the current working directory = project root.
# All output is also written to /tmp/system_update.log for debugging.
exec > >(tee -a /tmp/system_update.log) 2>&1
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
POETRY="$HOME/.local/bin/poetry"
OPENOCD_SRC="$HOME/sw_openocd"
OPENOCD_REPO="ssh://git@gitea.summitwave.work:222/auracaster/sw_openocd.git"
OPENOCD_BRANCH="change-8818"
OPENOCD_MARKER="$OPENOCD_SRC/.last_built_commit"
OPENOCD_DIR="$PROJECT_ROOT/src/openocd"
echo "[system_update] Starting post-checkout update. project_root=$PROJECT_ROOT"
# 1. poetry install
echo "[system_update] Running poetry install..."
(cd "$PROJECT_ROOT" && "$POETRY" install)
if [ $? -ne 0 ]; then
echo "[system_update] ERROR: poetry install failed"
exit 1
fi
# 2. Clone/update and build sw_openocd if needed
if [ ! -d "$OPENOCD_SRC" ]; then
echo "[system_update] Installing sw_openocd build dependencies..."
sudo apt install -y git build-essential libtool autoconf texinfo \
libusb-1.0-0-dev libftdi1-dev libhidapi-dev pkg-config || \
echo "[system_update] WARNING: apt install deps had errors, continuing"
sudo apt-get install -y pkg-config libjim-dev || \
echo "[system_update] WARNING: apt-get install libjim-dev had errors, continuing"
echo "[system_update] Cloning sw_openocd branch $OPENOCD_BRANCH..."
git clone --branch "$OPENOCD_BRANCH" --single-branch "$OPENOCD_REPO" "$OPENOCD_SRC"
if [ $? -ne 0 ]; then
echo "[system_update] ERROR: git clone sw_openocd failed"
exit 1
fi
else
echo "[system_update] Updating sw_openocd..."
git -C "$OPENOCD_SRC" fetch origin "$OPENOCD_BRANCH"
git -C "$OPENOCD_SRC" checkout "$OPENOCD_BRANCH"
git -C "$OPENOCD_SRC" pull
fi
OPENOCD_COMMIT=$(git -C "$OPENOCD_SRC" rev-parse HEAD)
LAST_BUILT=""
[ -f "$OPENOCD_MARKER" ] && LAST_BUILT=$(cat "$OPENOCD_MARKER")
if [ "$OPENOCD_COMMIT" != "$LAST_BUILT" ]; then
echo "[system_update] Building sw_openocd (commit $OPENOCD_COMMIT)..."
(cd "$OPENOCD_SRC" && ./bootstrap)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd bootstrap failed"; exit 1; fi
(cd "$OPENOCD_SRC" && ./configure --enable-bcm2835gpio --enable-sysfsgpio)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd configure failed"; exit 1; fi
(cd "$OPENOCD_SRC" && make)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make failed"; exit 1; fi
(cd "$OPENOCD_SRC" && sudo make install)
if [ $? -ne 0 ]; then echo "[system_update] ERROR: openocd make install failed"; exit 1; fi
echo "$OPENOCD_COMMIT" > "$OPENOCD_MARKER"
echo "[system_update] sw_openocd built and installed (commit $OPENOCD_COMMIT)"
else
echo "[system_update] sw_openocd up to date (commit $OPENOCD_COMMIT), skipping build"
fi
# 3. Flash firmware to both SWD interfaces
FLASH_SCRIPT="$OPENOCD_DIR/flash.sh"
HEX_FILE="$OPENOCD_DIR/merged.hex"
for IFACE in swd0 swd1; do
echo "[system_update] Flashing $IFACE..."
(cd "$OPENOCD_DIR" && bash "$FLASH_SCRIPT" -i "$IFACE" -f "$HEX_FILE")
if [ $? -ne 0 ]; then
echo "[system_update] ERROR: flash $IFACE failed"
exit 1
fi
echo "[system_update] Flash $IFACE complete"
done
# 4. Restart services (this will kill this process too)
echo "[system_update] Restarting services..."
bash "$PROJECT_ROOT/src/service/update_and_run_server_and_frontend.sh"

View File

@@ -6,8 +6,8 @@ pcm.ch1 {
channels 2
rate 48000
format S16_LE
period_size 120
buffer_size 240
period_size 240
buffer_size 960
}
bindings.0 0
}
@@ -21,8 +21,8 @@ pcm.ch2 {
channels 2
rate 48000
format S16_LE
period_size 120
buffer_size 240
period_size 240
buffer_size 960
}
bindings.0 1
}

View File

@@ -1 +1,2 @@
sudo cp src/misc/asound.conf /etc/asound.conf
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
sudo cp "$SCRIPT_DIR/asound.conf" /etc/asound.conf

55
src/openocd/flash.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
set -e
INTERFACE="swd0"
HEX_FILE=""
usage() {
echo "Usage: $0 -f <hex_file> [-i swd0|swd1]"
exit 1
}
while getopts "f:i:h" opt; do
case "$opt" in
f) HEX_FILE="$OPTARG" ;;
i)
if [[ "$OPTARG" == "swd0" || "$OPTARG" == "swd1" ]]; then
INTERFACE="$OPTARG"
else
usage
fi
;;
h) usage ;;
*) usage ;;
esac
done
[[ -n "$HEX_FILE" ]] || usage
[[ -f "$HEX_FILE" ]] || { echo "HEX file not found: $HEX_FILE"; exit 1; }
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "reset init" \
-c "flash banks" \
-c "flash write_image $HEX_FILE" \
-c "verify_image $HEX_FILE" \
-c "reset run" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x1" \
-c "sleep 100" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
sudo openocd \
-f ./raspberrypi-${INTERFACE}.cfg \
-c "init" \
-c "nrf54l.dap apreg 2 0x000 0x4" \
-c "nrf54l.dap apreg 2 0x000 0x0" \
-c "shutdown"
echo "Flashing complete."

13111
src/openocd/merged.hex Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,4 +5,9 @@ adapter gpio swdio 26
#adapter gpio trst 26
#reset_config trst_only
source [find target/nordic/nrf54l.cfg]
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
adapter speed 1000

View File

@@ -5,4 +5,9 @@ adapter gpio swdio 24
#adapter gpio trst 27
#reset_config trst_only
source [find target/nordic/nrf54l.cfg]
flash bank $_CHIPNAME.flash nrf54 0x00000000 0 0 0 $_TARGETNAME
adapter speed 1000

29
src/service/update_and_run_server_and_frontend.sh Normal file → Executable file
View File

@@ -4,6 +4,35 @@ set -e
# This script installs, enables, and restarts the auracast-server and auracast-frontend services
# Requires sudo privileges
# Ensure static link local is activated (for direct laptop connection)
# Enable link-local for all wired ethernet connections
while IFS=: read -r name type; do
if [[ "$type" == *"ethernet"* ]]; then
echo "Enabling IPv4 link-local for connection: $name"
sudo nmcli connection modify "$name" ipv4.link-local enabled 2>/dev/null || echo "Failed to modify $name"
sudo nmcli connection up "$name" 2>/dev/null || echo "Failed to bring up $name"
fi
done < <(nmcli -t -f NAME,TYPE connection show)
# Configure Avahi to prefer DHCP address over static fallback for mDNS
# Get the DHCP-assigned IP (first non-localhost, non-192.168.42.10 IP)
DHCP_IP=$(ip -4 addr show eth0 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | grep -v '^127\.' | grep -v '^169\.254\.' | head -n1)
HOSTNAME=$(hostname)
if [ -n "$DHCP_IP" ]; then
echo "DHCP address detected: $DHCP_IP, configuring Avahi to prefer it for mDNS."
# Add entry to /etc/avahi/hosts to explicitly map hostname to DHCP IP
sudo mkdir -p /etc/avahi
echo "$DHCP_IP $HOSTNAME $HOSTNAME.local" | sudo tee /etc/avahi/hosts > /dev/null
# Restart avahi to apply the hosts file
sudo systemctl restart avahi-daemon
else
echo "No DHCP address detected, mDNS will use link local"
# Remove hosts file to let Avahi advertise all IPs
sudo rm -f /etc/avahi/hosts
sudo systemctl restart avahi-daemon
fi
# Copy system service file for frontend
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service