Merge branch 'main' into feature/blue_led
This commit is contained in:
@@ -4,8 +4,11 @@
|
|||||||
- this projects uses poetry for package management
|
- this projects uses poetry for package management
|
||||||
- if something should be run in a python env use 'poetry run'
|
- 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
|
## Application
|
||||||
- this is a bluetooth Auracast transmitter 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
|
- 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.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class AuracastBigConfig(BaseModel):
|
|||||||
precode_wav: bool = False
|
precode_wav: bool = False
|
||||||
iso_que_len: int = 64
|
iso_que_len: int = 64
|
||||||
num_bis: int = 1 # 1 = mono (FRONT_LEFT), 2 = stereo (FRONT_LEFT + FRONT_RIGHT)
|
num_bis: int = 1 # 1 = mono (FRONT_LEFT), 2 = stereo (FRONT_LEFT + FRONT_RIGHT)
|
||||||
|
input_gain_db: float = 0.0 # Software gain boost in dB applied before LC3 encoding (0 = off, max 20)
|
||||||
|
|
||||||
class AuracastBigConfigDeu(AuracastBigConfig):
|
class AuracastBigConfigDeu(AuracastBigConfig):
|
||||||
id: int = 12
|
id: int = 12
|
||||||
|
|||||||
@@ -638,6 +638,12 @@ class Streamer():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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):
|
async def stream(self):
|
||||||
|
|
||||||
bigs = self.bigs
|
bigs = self.bigs
|
||||||
@@ -805,6 +811,13 @@ class Streamer():
|
|||||||
big['encoder'] = encoders[0]
|
big['encoder'] = encoders[0]
|
||||||
big['precoded'] = False
|
big['precoded'] = False
|
||||||
|
|
||||||
|
# Pre-compute software gain multiplier from dB config (0 dB = 1.0 = no change)
|
||||||
|
gain_db = getattr(big_config[i], 'input_gain_db', 0.0)
|
||||||
|
gain_db = max(0.0, min(20.0, float(gain_db)))
|
||||||
|
big['_gain_linear'] = 10.0 ** (gain_db / 20.0) if gain_db > 0 else 0.0
|
||||||
|
if big['_gain_linear'] > 0.0:
|
||||||
|
logging.info("Software gain for BIG %d: +%.1f dB (linear %.3f)", i, gain_db, big['_gain_linear'])
|
||||||
|
|
||||||
logging.info("Streaming audio...")
|
logging.info("Streaming audio...")
|
||||||
bigs = self.bigs
|
bigs = self.bigs
|
||||||
self.is_streaming = True
|
self.is_streaming = True
|
||||||
@@ -852,6 +865,19 @@ class Streamer():
|
|||||||
stream_finished[i] = True
|
stream_finished[i] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Apply software gain boost if configured (> 0 dB)
|
||||||
|
gain_lin = big.get('_gain_linear', 0.0)
|
||||||
|
if gain_lin > 0.0:
|
||||||
|
pcm_arr = np.frombuffer(pcm_frame, dtype=np.int16).astype(np.float32)
|
||||||
|
pcm_arr *= gain_lin
|
||||||
|
np.clip(pcm_arr, -32768, 32767, out=pcm_arr)
|
||||||
|
pcm_frame = pcm_arr.astype(np.int16).tobytes()
|
||||||
|
|
||||||
|
# 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
|
# Measure LC3 encoding time
|
||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
num_bis = big.get('num_bis', 1)
|
num_bis = big.get('num_bis', 1)
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class Multicaster:
|
|||||||
'is_initialized': self.is_auracast_init,
|
'is_initialized': self.is_auracast_init,
|
||||||
'is_streaming': streaming,
|
'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):
|
async def init_broadcast(self):
|
||||||
self.device_acm = multicast.create_device(self.global_conf)
|
self.device_acm = multicast.create_device(self.global_conf)
|
||||||
|
|||||||
42
src/auracast/server/http_to_https_redirect.py
Normal file
42
src/auracast/server/http_to_https_redirect.py
Normal 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()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# frontend/app.py
|
# frontend/app.py
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import math
|
||||||
import logging as log
|
import logging as log
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
@@ -197,18 +198,85 @@ else:
|
|||||||
start_stream, stop_stream = render_stream_controls(is_streaming, "Start Auracast", "Stop Auracast", running_mode, secondary_is_streaming)
|
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 control (only for Analog mode, placed below start button)
|
||||||
analog_gain_value = 50 # default
|
analog_gain_value = 50 # default (ALSA 10-60 range)
|
||||||
|
software_boost_db = 0 # default
|
||||||
if audio_mode == "Analog":
|
if audio_mode == "Analog":
|
||||||
saved_analog_gain = saved_settings.get('analog_gain', 50)
|
saved_analog_gain = saved_settings.get('analog_gain', 50)
|
||||||
analog_gain_value = st.slider(
|
# Convert persisted ALSA value (10-60) to display value (0-100)
|
||||||
|
saved_display = int(round((saved_analog_gain - 10) * 100 / 50))
|
||||||
|
saved_display = max(0, min(100, saved_display))
|
||||||
|
analog_gain_display = st.slider(
|
||||||
"Analog Input Gain",
|
"Analog Input Gain",
|
||||||
min_value=10,
|
min_value=0,
|
||||||
max_value=60,
|
max_value=100,
|
||||||
value=min(saved_analog_gain, 60),
|
value=saved_display,
|
||||||
step=5,
|
step=5,
|
||||||
disabled=is_streaming,
|
disabled=is_streaming,
|
||||||
help="ADC gain level for both analog inputs (10-60%). Default is 50%."
|
format="%d%%",
|
||||||
|
help="ADC gain level for both analog inputs. Default is 80%."
|
||||||
)
|
)
|
||||||
|
# Map display value (0-100) back to ALSA range (10-60)
|
||||||
|
analog_gain_value = int(round(10 + analog_gain_display * 50 / 100))
|
||||||
|
saved_boost = saved_settings.get('software_boost_db', 0)
|
||||||
|
software_boost_db = st.slider(
|
||||||
|
"Boost",
|
||||||
|
min_value=0,
|
||||||
|
max_value=20,
|
||||||
|
value=min(int(saved_boost), 20),
|
||||||
|
step=1,
|
||||||
|
disabled=is_streaming,
|
||||||
|
help="Digital gain boost applied before encoding (0-20 dB). Use this when the line-level signal is too quiet even at max ADC gain. Higher values may cause clipping on loud signals."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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** --")
|
||||||
|
# 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** --")
|
||||||
|
_audio_level_fragment()
|
||||||
|
|
||||||
# Placeholder for validation errors (will be filled in later)
|
# Placeholder for validation errors (will be filled in later)
|
||||||
validation_error_placeholder = st.empty()
|
validation_error_placeholder = st.empty()
|
||||||
@@ -617,6 +685,7 @@ else:
|
|||||||
'presentation_delay_ms': presentation_delay_ms2,
|
'presentation_delay_ms': presentation_delay_ms2,
|
||||||
'qos_preset': qos_preset2,
|
'qos_preset': qos_preset2,
|
||||||
'analog_gain': analog_gain_value,
|
'analog_gain': analog_gain_value,
|
||||||
|
'software_boost_db': software_boost_db,
|
||||||
}
|
}
|
||||||
|
|
||||||
radio1_cfg = {
|
radio1_cfg = {
|
||||||
@@ -633,6 +702,7 @@ else:
|
|||||||
'qos_preset': qos_preset1,
|
'qos_preset': qos_preset1,
|
||||||
'stereo_mode': stereo_enabled,
|
'stereo_mode': stereo_enabled,
|
||||||
'analog_gain': analog_gain_value,
|
'analog_gain': analog_gain_value,
|
||||||
|
'software_boost_db': software_boost_db,
|
||||||
}
|
}
|
||||||
|
|
||||||
if audio_mode == "Network - Dante":
|
if audio_mode == "Network - Dante":
|
||||||
@@ -1518,7 +1588,6 @@ if start_stream:
|
|||||||
analog_gain=cfg.get('analog_gain', 50),
|
analog_gain=cfg.get('analog_gain', 50),
|
||||||
bigs=[
|
bigs=[
|
||||||
auracast_config.AuracastBigConfig(
|
auracast_config.AuracastBigConfig(
|
||||||
id=cfg.get('id', 123456),
|
|
||||||
code=(cfg['stream_passwort'].strip() or None),
|
code=(cfg['stream_passwort'].strip() or None),
|
||||||
name=cfg['name'],
|
name=cfg['name'],
|
||||||
program_info=cfg['program_info'],
|
program_info=cfg['program_info'],
|
||||||
@@ -1529,6 +1598,7 @@ if start_stream:
|
|||||||
sampling_frequency=q['rate'],
|
sampling_frequency=q['rate'],
|
||||||
octets_per_frame=q['octets'],
|
octets_per_frame=q['octets'],
|
||||||
num_bis=channels, # 1=mono, 2=stereo - this determines the behavior
|
num_bis=channels, # 1=mono, 2=stereo - this determines the behavior
|
||||||
|
input_gain_db=float(cfg.get('software_boost_db', 0)),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1572,8 +1642,6 @@ if start_stream:
|
|||||||
if not stream.get('input_device'):
|
if not stream.get('input_device'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
stream_id = radio_id * 1000 + i + 1 # Unique ID per stream
|
|
||||||
|
|
||||||
# Check if this specific stream uses stereo (dante_stereo_X_Y device)
|
# Check if this specific stream uses stereo (dante_stereo_X_Y device)
|
||||||
input_device = stream['input_device']
|
input_device = stream['input_device']
|
||||||
stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_')
|
stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_')
|
||||||
@@ -1581,7 +1649,6 @@ if start_stream:
|
|||||||
num_channels = 2 if stream_is_stereo else 1
|
num_channels = 2 if stream_is_stereo else 1
|
||||||
|
|
||||||
bigs.append(auracast_config.AuracastBigConfig(
|
bigs.append(auracast_config.AuracastBigConfig(
|
||||||
id=stream_id,
|
|
||||||
code=(stream.get('stream_password', '').strip() or None),
|
code=(stream.get('stream_password', '').strip() or None),
|
||||||
name=stream['name'],
|
name=stream['name'],
|
||||||
program_info=stream['program_info'],
|
program_info=stream['program_info'],
|
||||||
@@ -1713,6 +1780,123 @@ with st.expander("System control", expanded=False):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.warning(f"Could not read temperatures: {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.subheader("CA Certificate")
|
||||||
st.caption("Download the CA certificate to trust this device's HTTPS connection.")
|
st.caption("Download the CA certificate to trust this device's HTTPS connection.")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ def save_settings(persisted: dict, secondary: bool = False) -> None:
|
|||||||
def gen_random_add() -> str:
|
def gen_random_add() -> str:
|
||||||
return ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
|
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()
|
app = FastAPI()
|
||||||
|
|
||||||
# Allow CORS for frontend on localhost
|
# Allow CORS for frontend on localhost
|
||||||
@@ -502,10 +506,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
|
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:
|
for big in conf.bigs:
|
||||||
if not getattr(big, 'random_address', None) or big.random_address == DEFAULT_RANDOM_ADDRESS:
|
if not getattr(big, 'random_address', None) or big.random_address == DEFAULT_RANDOM_ADDRESS:
|
||||||
big.random_address = gen_random_add()
|
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 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))
|
log.info('Final multicaster config (transport=%s):\n %s', transport, conf.model_dump_json(indent=2))
|
||||||
@@ -542,6 +548,7 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
|
|||||||
'assisted_listening_stream': getattr(conf, 'assisted_listening_stream', 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_stereo_mode': getattr(conf.bigs[0], 'analog_stereo_mode', False) if conf.bigs else False,
|
||||||
'analog_gain': getattr(conf, 'analog_gain', 50),
|
'analog_gain': getattr(conf, 'analog_gain', 50),
|
||||||
|
'software_boost_db': getattr(conf.bigs[0], 'input_gain_db', 0.0) if conf.bigs else 0.0,
|
||||||
'stream_password': (conf.bigs[0].code if conf.bigs and getattr(conf.bigs[0], 'code', None) else None),
|
'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_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],
|
'big_random_addresses': [getattr(big, 'random_address', DEFAULT_RANDOM_ADDRESS) for big in conf.bigs],
|
||||||
@@ -653,6 +660,20 @@ async def get_status():
|
|||||||
|
|
||||||
return status
|
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():
|
async def _autostart_from_settings():
|
||||||
settings1 = load_stream_settings() or {}
|
settings1 = load_stream_settings() or {}
|
||||||
settings2 = load_stream_settings2() or {}
|
settings2 = load_stream_settings2() or {}
|
||||||
@@ -796,6 +817,7 @@ async def _autostart_from_settings():
|
|||||||
iso_que_len=1,
|
iso_que_len=1,
|
||||||
sampling_frequency=rate,
|
sampling_frequency=rate,
|
||||||
octets_per_frame=octets,
|
octets_per_frame=octets,
|
||||||
|
input_gain_db=float(settings.get('software_boost_db', 0)),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
conf = auracast_config.AuracastConfigGroup(
|
conf = auracast_config.AuracastConfigGroup(
|
||||||
@@ -950,6 +972,7 @@ async def _autostart_from_settings():
|
|||||||
iso_que_len=1,
|
iso_que_len=1,
|
||||||
sampling_frequency=rate,
|
sampling_frequency=rate,
|
||||||
octets_per_frame=octets,
|
octets_per_frame=octets,
|
||||||
|
input_gain_db=float(settings.get('software_boost_db', 0)),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
conf = auracast_config.AuracastConfigGroup(
|
conf = auracast_config.AuracastConfigGroup(
|
||||||
@@ -1508,6 +1531,212 @@ async def delete_recordings():
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
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__':
|
if __name__ == '__main__':
|
||||||
import os
|
import os
|
||||||
os.chdir(os.path.dirname(__file__))
|
os.chdir(os.path.dirname(__file__))
|
||||||
|
|||||||
@@ -33,5 +33,12 @@ echo "Using Avahi domain: $AVAHI_DOMAIN"
|
|||||||
# Path to poetry binary
|
# Path to poetry binary
|
||||||
POETRY_BIN="/home/caster/.local/bin/poetry"
|
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)
|
# 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
|
$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
|
||||||
|
|||||||
@@ -4,6 +4,35 @@ set -e
|
|||||||
# This script installs, enables, and restarts the auracast-server and auracast-frontend services
|
# This script installs, enables, and restarts the auracast-server and auracast-frontend services
|
||||||
# Requires sudo privileges
|
# 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
|
# Copy system service file for frontend
|
||||||
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
|
sudo cp /home/caster/bumble-auracast/src/service/auracast-frontend.service /etc/systemd/system/auracast-frontend.service
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user