Compare commits

..

11 Commits

74 changed files with 13129 additions and 13718 deletions
-1
View File
@@ -13,7 +13,6 @@ auracast.egg-info/
# Ignore virtual environment data
venv/
.venv/
env/
# Ignore any IDE configurations or project-specific metadata
-3
View File
@@ -1,3 +0,0 @@
[submodule "firmware"]
path = firmware
url = ssh://git@gitea.summitwave.work:222/auracaster/hci_uart_beacon.git
-60
View File
@@ -1,60 +0,0 @@
# CLAUDE.md
Guidance for Claude Code when working in this repo.
## Deploying to a beacon dev device
Devices (e.g. **beacon29**) run the Auracast frontend + backend as systemd services.
The deploy workflow is a **whole-tree rsync** of the local working copy onto the device,
followed by re-running the service update script. The device's `.git` is pinned to an old
commit while its files drift ahead, so `git status` on the device looks noisy — that is
expected, do not try to "fix" it with `git pull`/checkout.
### 1. Sync (laptop -> device)
```bash
rsync -avz \
--filter=':- .gitignore' \
--exclude='.git/' \
--exclude='firmware/' \
--exclude='src/dep/dante_package/bundle/' \
--exclude='src/dep/dante_package/dante_data/activation/' \
--delete \
/home/paul/Documents/bumble-auracast/ \
caster@beacon29.local:/home/caster/bumble-auracast/
```
- ⚠️ **NEVER delete or overwrite the Dante activation files/dir**
(`src/dep/dante_package/dante_data/activation/` — contains `device.lic`,
`manufacturer.cert`). It is per-device licensing and is unrecoverable if lost.
The `--exclude` rules above protect it (with `--delete`, excluded paths are left
untouched). Keep those excludes, and never use `--delete-excluded`.
- Always **dry-run first** (`rsync -avzn ...`) and confirm there are no `deleting`
lines touching `activation/` or `bundle/` before doing the real sync.
- Target by hostname. **beacon29 = `beacon29.local` (10.11.0.59).** Don't blindly reuse
IPs from old commands (an earlier example used `10.11.0.48`, a different device).
- The `firmware/` submodule (nRF firmware source + ~GB nRF Connect SDK build tree) is
**excluded** — devices only need the committed `src/openocd/merged.hex`, which still
syncs. Rebuild the hex on the laptop with `src/openocd/update_firmware.sh`; never build
firmware on the device.
### 2. Restart services on the device
```bash
ssh caster@beacon29.local '
export XDG_RUNTIME_DIR=/run/user/$(id -u) # needed for the `systemctl --user` backend restart over SSH
cd ~/bumble-auracast
bash src/service/update_and_run_server_and_frontend.sh
'
```
This restarts `dep.service`, `auracast-frontend.service` (system), and
`auracast-server.service` (user). For a **frontend-only** change you can instead just
`sudo systemctl restart auracast-frontend.service`. A benign
`Failed to bring up Wired connection 2` line is normal when the 2nd ethernet port is
unplugged.
### 3. Verify
- Frontend serves HTTPS: `curl -sk -o /dev/null -w "%{http_code}\n" https://beacon29.local/``200`.
- `caster@beacon29.local` has passwordless SSH and passwordless sudo.
-37
View File
@@ -61,43 +61,6 @@ sudo ./provision_domain_hostname.sh <new_hostname> <new_domain>
- If you have issues with mDNS name resolution, check for conflicting mDNS stacks (e.g., systemd-resolved, Bonjour, or other daemons).
- Some Linux clients may not resolve multi-label mDNS names via NSS—test with `avahi-resolve-host-name` and try from another device if needed.
## Beacon firmware (nRF54L15)
The two nRF54L15 radios on a beacon are flashed with `src/openocd/merged.hex`
(via `src/openocd/flash.sh`, driven by `src/auracast/server/system_update.sh`).
- **Firmware source** lives in the `firmware/` git submodule (`hci_uart_beacon`).
Clone with submodules:
```bash
git clone --recurse-submodules <repo>
# or, in an existing clone:
git submodule update --init firmware
```
- **`src/openocd/merged.hex` is a committed build artifact.** Beacon devices have no
nRF Connect SDK toolchain, so they flash this prebuilt hex directly — you do **not**
need the submodule or the toolchain just to deploy.
- **To rebuild the firmware and refresh `merged.hex`** (requires the nRF Connect SDK
under `~/ncs`):
```bash
git submodule update --init firmware
# optional: check out a different firmware commit inside firmware/
src/openocd/update_firmware.sh --build
git add firmware src/openocd/merged.hex src/openocd/merged.hex.version
```
`--build` is a one-shot: it configures **and** builds the `build_nrf54_radio0_radio1`
sysbuild image from the checked-out firmware commit (via
`nrfutil toolchain-manager launch --ncs-version v3.0.2` → `west build --sysbuild`),
copies the resulting `merged.hex` into `src/openocd/`, and records provenance in
`src/openocd/merged.hex.version`. BOARD and the conf/overlay files are read straight
from `firmware/CMakePresets.json`, so it stays in sync with the nRF Connect VS Code
preset. Override with `--preset NAME` / `--ncs-version VER`. `merged.hex` is a
*sysbuild* output — the script refuses a plain `zephyr.hex` fallback.
- **To just re-export** an already-built tree (no rebuild), run
`src/openocd/update_firmware.sh` with no `--build`.
- **Deploy note:** the whole-tree rsync deploy must exclude the firmware tree
(`--exclude='firmware/'`) — devices only need the committed hex, not the GB-scale
toolchain / build tree.
# record audio and save to file for debugging
pw-record --target="AVIOUSB-8f6326 : 2:receive_Left" --rate=48000 --channels=1 --format=s24 /tmp/aes67_test.wav &
RECORD_PID=$!
Submodule firmware deleted from 52d034e58e
+9 -26
View File
@@ -1,11 +1,5 @@
from typing import List
from pydantic import BaseModel, field_validator
# Discrete TX power levels (dBm) supported by the Nordic SoftDevice Controller
# for the nRF radio PA. The HCI controller will clamp requested values to the
# nearest supported step. The maximum is bounded by CONFIG_BT_CTLR_TX_PWR_*
# in the hci_uart firmware (currently +8 dBm).
TX_POWER_VALID = [8, 7, 6, 5, 4, 3, 2, 0, -4, -8, -12, -16, -20]
from pydantic import BaseModel
# Define some base to hold the relevant parameters
class AuracastQoSConfig(BaseModel):
@@ -34,24 +28,13 @@ class AuracastGlobalConfig(BaseModel):
octets_per_frame: int = 40 #48kbps@24kHz # bitrate = octets_per_frame * 8 / frame len
frame_duration_us: int = 10000
presentation_delay_us: int = 40000
# TODO:pydantic does not support bytes serialization - use .hex and np.fromhex()
manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None)
# LE Audio: Broadcast Audio Immediate Rendering (metadata type 0x09)
# When true, include a zero-length LTV with type 0x09 in the subgroup metadata
# so receivers may render earlier than the presentation delay for lower latency.
immediate_rendering: bool = False
assisted_listening_stream: bool = False
# Bluetooth advertising TX power for this radio in dBm (per advertising set).
# Sent through HCI_LE_Set_Extended_Advertising_Parameters; the SDC clamps to
# nearest supported hardware step and propagates to primary/secondary adv,
# the periodic advertising train and the BIS ISO PDUs.
advertising_tx_power: int = 8
@field_validator('advertising_tx_power')
@classmethod
def _snap_tx_power(cls, v: int) -> int:
# Snap to the nearest supported discrete step in TX_POWER_VALID.
if v in TX_POWER_VALID:
return v
return min(TX_POWER_VALID, key=lambda s: abs(s - v))
# "Audio input. "
# "'device' -> use the host's default sound input device, "
@@ -79,7 +62,7 @@ class AuracastBigConfigDeu(AuracastBigConfig):
name: str = 'Hörsaal A'
language: str ='deu'
program_info: str = 'Vorlesung DE'
audio_source: str = 'file:./testdata/wave_particle_5min_de.lc3'
audio_source: str = 'file:./testdata/wave_particle_5min_de.wav'
class AuracastBigConfigEng(AuracastBigConfig):
id: int = 123
@@ -87,7 +70,7 @@ class AuracastBigConfigEng(AuracastBigConfig):
name: str = 'Lecture Hall A'
language: str ='eng'
program_info: str = 'Lecture EN'
audio_source: str = 'file:./testdata/wave_particle_5min_en.lc3'
audio_source: str = 'file:./testdata/wave_particle_5min_en.wav'
class AuracastBigConfigFra(AuracastBigConfig):
id: int = 1234
@@ -96,7 +79,7 @@ class AuracastBigConfigFra(AuracastBigConfig):
name: str = 'Auditoire A'
language: str ='fra'
program_info: str = 'Auditoire FR'
audio_source: str = 'file:./testdata/wave_particle_5min_fr.lc3'
audio_source: str = 'file:./testdata/wave_particle_5min_fr.wav'
class AuracastBigConfigSpa(AuracastBigConfig):
id: int =12345
@@ -104,7 +87,7 @@ class AuracastBigConfigSpa(AuracastBigConfig):
name: str = 'Auditorio A'
language: str ='spa'
program_info: str = 'Auditorio ES'
audio_source: str = 'file:./testdata/wave_particle_5min_es.lc3'
audio_source: str = 'file:./testdata/wave_particle_5min_es.wav'
class AuracastBigConfigIta(AuracastBigConfig):
id: int =1234567
@@ -112,7 +95,7 @@ class AuracastBigConfigIta(AuracastBigConfig):
name: str = 'Aula A'
language: str ='ita'
program_info: str = 'Aula IT'
audio_source: str = 'file:./testdata/wave_particle_5min_it.lc3'
audio_source: str = 'file:./testdata/wave_particle_5min_it.wav'
class AuracastBigConfigPol(AuracastBigConfig):
@@ -121,7 +104,7 @@ class AuracastBigConfigPol(AuracastBigConfig):
name: str = 'Sala Wykładowa'
language: str ='pol'
program_info: str = 'Sala Wykładowa PL'
audio_source: str = 'file:./testdata/wave_particle_5min_pl.lc3'
audio_source: str = 'file:./testdata/wave_particle_5min_pl.wav'
class AuracastConfigGroup(AuracastGlobalConfig):
+26 -93
View File
@@ -49,7 +49,6 @@ import bumble.transport
import bumble.utils
from bumble.device import Host, AdvertisingChannelMap
from bumble.audio import io as audio_io
from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
from auracast import auracast_config
from auracast.utils.read_lc3_file import read_lc3_file
@@ -463,6 +462,21 @@ async def init_broadcast(
],
)
logger.info('Setup Advertising')
advertising_manufacturer_data = (
b''
if global_config.manufacturer_data == (None, None)
else bytes(
core.AdvertisingData(
[
(
core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
struct.pack('<H', global_config.manufacturer_data[0])
+ global_config.manufacturer_data[1],
)
]
)
)
)
bigs[f'big{i}']['broadcast_audio_announcement'] = bap.BroadcastAudioAnnouncement(conf.id)
# Build advertising data types list
@@ -505,84 +519,23 @@ async def init_broadcast(
advertising_sid=i,
primary_advertising_phy=hci.Phy.LE_1M, # 2m phy config throws error - because for primary advertising channels, 1mbit is only supported
secondary_advertising_phy=hci.Phy.LE_1M, # this is the secondary advertising beeing send on non advertising channels (extendend advertising)
# Pass NO_PREFERENCE (0x7F) here for two reasons:
# 1. The Nordic SoftDevice Controller ignores this field for
# advertising sets and always returns the compile-time
# CONFIG_BT_CTLR_TX_PWR_* value. The real TX power is
# applied via the Zephyr VS Write_Tx_Power_Level command
# issued right after create_advertising_set() returns.
# 2. Bumble's HCI metadata declares this field as 1-byte
# *unsigned* (a bumble bug — the BT spec defines it as
# signed int8), so negative values would raise
# "bytes must be in range(0, 256)" at serialization.
advertising_tx_power=hci.HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE,
#advertising_tx_power= # tx power in dbm (max 20)
#secondary_advertising_max_skip=10,
),
advertising_data=(
bigs[f'big{i}']['broadcast_audio_announcement'].get_advertising_data()
+ bytes(core.AdvertisingData(advertising_data_types))
+ advertising_manufacturer_data
),
periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
# 113 * 1.25 ms = 141.25 ms. Deliberately NON-commensurate with the
# 10 ms ISO interval (14.125x, a 1/8-ISO fractional step) so the PA
# anchor walks through the ISO cycle instead of standing on it. A
# commensurate value (e.g. 80 = 100 ms = 10x) freezes the PA/BIG phase
# once the broadcaster clock is stable (LFXO), so every PA event
# collides with a BIG subevent and gets dropped in favour of the
# audio -> receivers that keep the PA synced during streaming starve
# it out and hit the 10 s supervision timeout (PA drop). The walk keeps
# the PA landing in free slots so the sync holds. Verified with an HCI
# sniffer: commensurate -> PA 0/s, lost @10 s; 141.25 ms -> ~2/s, holds.
periodic_advertising_interval_min=113,
periodic_advertising_interval_max=113,
periodic_advertising_interval_min=80,
periodic_advertising_interval_max=160,
),
periodic_advertising_data=bigs[f'big{i}']['basic_audio_announcement'].get_advertising_data(),
auto_restart=True,
auto_start=True,
)
bigs[f'big{i}']['advertising_set'] = advertising_set
# NOTE: selected_tx_power below reflects the SDC's compile-time max
# (LE_Set_Ext_Adv_Params was sent with NO_PREFERENCE). The actual
# transmit power is set by the VS Write_Tx_Power_Level call below.
logging.debug(
'LE_Set_Ext_Adv_Params reports controller fallback TX power: %+d dBm (handle=%d)',
getattr(advertising_set, 'selected_tx_power', 0),
i,
)
# The Nordic SoftDevice Controller does not honor the per-set
# advertising_tx_power passed in HCI_LE_Set_Extended_Advertising_Parameters
# (it returns the compile-time CONFIG_BT_CTLR_TX_PWR_* value regardless).
# Apply the requested level via the Zephyr Vendor-Specific HCI command
# Write_Tx_Power_Level (opcode 0xFC0E), which the SDC honors per
# advertising handle. The SDC clamps the value to the nearest supported
# hardware step (max bounded by CONFIG_BT_CTLR_TX_PWR_PLUS_8).
try:
adv_handle = getattr(advertising_set, 'advertising_handle', i)
response = await device.send_command(
HCI_Write_Tx_Power_Level_Command(
handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
connection_handle=adv_handle,
tx_power_level=global_config.advertising_tx_power,
)
)
rp = getattr(response, 'return_parameters', None)
status = getattr(rp, 'status', 0xFF) if rp is not None else 0xFF
selected = getattr(rp, 'selected_tx_power_level', None) if rp is not None else None
if status == 0 and selected is not None:
logging.info(
'Advertising TX power (VS Write_Tx_Power_Level): requested=%+d dBm, controller selected=%+d dBm (handle=%d)',
global_config.advertising_tx_power,
selected,
adv_handle,
)
else:
logging.warning(
'VS Write_Tx_Power_Level failed: status=0x%02X handle=%d requested=%+d dBm',
status, adv_handle, global_config.advertising_tx_power,
)
except Exception as e:
logging.warning('VS Write_Tx_Power_Level not supported by controller: %s', e)
logging.info('Start Periodic Advertising')
await advertising_set.start_periodic()
@@ -649,29 +602,6 @@ async def init_broadcast(
return bigs
def _lc3_file_byte_gen(filename: str, loop: bool = False):
"""Stream LC3 frames from disk as individual bytes, with optional looping.
Yields one byte (int) at a time so it is compatible with the existing
``bytes(itertools.islice(gen, bytes_per_frame))`` consumer without loading
the whole file into memory.
"""
while True:
with open(filename, 'rb') as f:
f.read(18) # skip 18-byte LC3 header
while True:
size_b = f.read(2)
if len(size_b) < 2:
break
frame_size = struct.unpack('=H', size_b)[0]
frame = f.read(frame_size)
if len(frame) < frame_size:
break
yield from frame
if not loop:
return
class Streamer():
"""
Streamer class that supports multiple input formats. See bumble for streaming from wav or device
@@ -827,7 +757,13 @@ class Streamer():
big['precoded'] = True
big['lc3_bytes_per_frame'] = global_config.octets_per_frame
filename = big_config[i].audio_source.replace('file:', '')
big['lc3_frames'] = _lc3_file_byte_gen(filename, loop=big_config[i].loop)
lc3_bytes = read_lc3_file(filename)
lc3_frames = iter(lc3_bytes)
if big_config[i].loop:
lc3_frames = itertools.cycle(lc3_frames)
big['lc3_frames'] = lc3_frames
# use wav files and code them entirely before streaming
elif big_config[i].precode_wav and big_config[i].audio_source.endswith('.wav'):
@@ -948,9 +884,6 @@ class Streamer():
if lc3_frame == b'': # Not all streams may stop at the same time
stream_finished[i] = True
continue
for q_idx in range(big.get('num_bis', 1)):
await big['iso_queues'][q_idx].write(lc3_frame)
else: # code lc3 on the fly with perf counters
# Ensure frames generator exists (so we can aclose() on stop)
frames_gen = big.get('frames_gen')
+31 -160
View File
@@ -100,36 +100,6 @@ QOS_PRESET_MAP = {
"Robust": auracast_config.AuracastQosRobust(),
}
# Discrete advertising TX power steps in dBm supported by the Nordic SDC radio
# PA. Sent through HCI_LE_Set_Extended_Advertising_Parameters; the controller
# clamps to the nearest hardware step.
TX_POWER_OPTIONS = [8, 7, 6, 5, 4, 3, 2, 0, -4, -8, -12, -16, -20]
TX_POWER_DEFAULT = 8
def _coerce_tx_power(value, default: int = TX_POWER_DEFAULT) -> int:
try:
v = int(value)
except (TypeError, ValueError):
return default
if v in TX_POWER_OPTIONS:
return v
return min(TX_POWER_OPTIONS, key=lambda s: abs(s - v))
def _tx_power_selectbox(label: str, key: str, default: int, disabled: bool, help_text: str | None = None) -> int:
snapped = _coerce_tx_power(default)
idx = TX_POWER_OPTIONS.index(snapped)
return st.selectbox(
label,
TX_POWER_OPTIONS,
index=idx,
key=key,
format_func=lambda v: f"{v:+d} dBm",
disabled=disabled,
help=help_text or "Bluetooth advertising TX power for this radio. Higher values increase range; lower values reduce interference and power draw.",
)
# Try loading persisted settings from backend
saved_settings = {}
try:
@@ -385,17 +355,6 @@ if audio_mode == "Demo":
disabled=is_streaming,
help="Select the demo stream configuration."
)
demo_content_options = ["Program material", "1 kHz test tone"]
saved_demo_content = saved_settings.get('demo_content', 'Program material')
if saved_demo_content not in demo_content_options:
saved_demo_content = 'Program material'
demo_content = st.selectbox(
"Demo Content",
demo_content_options,
index=demo_content_options.index(saved_demo_content),
disabled=is_streaming,
help="Select whether demo streams use program audio files or a continuous 1 kHz test tone."
)
# Stream password and flags (same as USB/AES67)
saved_pwd = saved_settings.get('stream_password', '') or ''
stream_passwort = st.text_input(
@@ -439,22 +398,6 @@ if audio_mode == "Demo":
disabled=is_streaming,
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
)
# Per-radio TX power for Demo (independent for R1 and R2)
col_tx_r1, col_tx_r2 = st.columns(2, gap="small")
with col_tx_r1:
tx_power_r1 = _tx_power_selectbox(
"TX Power (R1)",
key="demo_tx_power_r1",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
with col_tx_r2:
tx_power_r2 = _tx_power_selectbox(
"TX Power (R2)",
key="demo_tx_power_r2",
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
#st.info(f"Demo mode selected: {demo_selected} (Streams: {demo_stream_map[demo_selected]['streams']}, Rate: {demo_stream_map[demo_selected]['rate']} Hz)")
quality = None # Not used in demo mode
else:
@@ -486,14 +429,13 @@ else:
help="Radio 1 is always enabled, Radio 2 can be turned on or off."
)
# Stereo mode toggle for analog (temporarily hidden from the UI)
stereo_enabled = False
# stereo_enabled = st.checkbox(
# "🎧 Stereo Mode",
# value=bool(saved_settings.get('analog_stereo_mode', False)),
# help="Enable stereo streaming for analog inputs. When enabled, ch1 becomes left channel and ch2 becomes right channel in a single stereo stream. Radio 2 will be disabled in stereo mode.",
# disabled=is_streaming
# )
# Stereo mode toggle for analog
stereo_enabled = st.checkbox(
"🎧 Stereo Mode",
value=bool(saved_settings.get('analog_stereo_mode', False)),
help="Enable stereo streaming for analog inputs. When enabled, ch1 becomes left channel and ch2 becomes right channel in a single stereo stream. Radio 2 will be disabled in stereo mode.",
disabled=is_streaming
)
quality_options = list(QUALITY_MAP.keys())
@@ -582,13 +524,6 @@ else:
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
)
tx_power_r1 = _tx_power_selectbox(
"TX Power (R1)",
key="analog_tx_power_r1",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
col_r1_name, col_r1_lang = st.columns([2, 1])
with col_r1_name:
stream_name1 = st.text_input(
@@ -791,13 +726,6 @@ else:
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
)
tx_power_r2 = _tx_power_selectbox(
"TX Power (R2)",
key="analog_tx_power_r2",
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
col_r2_name, col_r2_lang = st.columns([2, 1])
with col_r2_name:
stream_name2 = st.text_input(
@@ -836,7 +764,7 @@ else:
else:
input_device2 = None
else:
input_device2 = saved_settings.get('secondary', {}).get('input_device')
input_device2 = saved_settings.get('input_device')
st.selectbox(
"Input Device (Radio 2)",
[input_device2 or "No device selected"],
@@ -857,7 +785,6 @@ else:
'immediate_rendering': immediate_rendering2,
'presentation_delay_ms': presentation_delay_ms2,
'qos_preset': qos_preset2,
'tx_power': tx_power_r2,
'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right,
}
@@ -874,7 +801,6 @@ else:
'immediate_rendering': immediate_rendering1,
'presentation_delay_ms': presentation_delay_ms1,
'qos_preset': qos_preset1,
'tx_power': tx_power_r1,
'stereo_mode': stereo_enabled,
'analog_gain_db_left': analog_gain_db_left,
'analog_gain_db_right': analog_gain_db_right,
@@ -919,19 +845,19 @@ else:
help="Radio 1 is always enabled, Radio 2 can be turned on or off."
)
# Dante stereo mode toggle (temporarily hidden from the UI)
# Dante stereo mode toggle
saved_audio_mode = saved_settings.get('audio_mode')
dante_stereo_enabled = False
# if saved_audio_mode == 'Network - Dante':
# # Check if any input device starts with dante_stereo_ to detect stereo mode
# input_device = saved_settings.get('input_device', '')
# dante_stereo_enabled = input_device.startswith('dante_stereo_')
# dante_stereo_enabled = st.checkbox(
# "🎧 Stereo Mode",
# value=dante_stereo_enabled,
# help="Enable stereo streaming for Dante inputs. Select left and right channels from ASRC channels 1-6. Radio 2 and multi-stream configurations will be disabled in stereo mode.",
# disabled=is_streaming
# )
if saved_audio_mode == 'Network - Dante':
# Check if any input device starts with dante_stereo_ to detect stereo mode
input_device = saved_settings.get('input_device', '')
dante_stereo_enabled = input_device.startswith('dante_stereo_')
dante_stereo_enabled = st.checkbox(
"🎧 Stereo Mode",
value=dante_stereo_enabled,
help="Enable stereo streaming for Dante inputs. Select left and right channels from ASRC channels 1-6. Radio 2 and multi-stream configurations will be disabled in stereo mode.",
disabled=is_streaming
)
# Dante stereo channel selectors
dante_left_channel = None
@@ -1092,14 +1018,7 @@ else:
disabled=is_streaming,
help="Quality of Service preset for Radio 1"
)
r1_tx_power = _tx_power_selectbox(
"TX Power (R1)",
key="dante_tx_power_r1",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
# Per-stream configuration for Radio 1
if dante_stereo_enabled:
st.write("**Stereo Stream Configuration (Radio 1)**")
@@ -1425,14 +1344,7 @@ else:
disabled=is_streaming,
help="Quality of Service preset for Radio 2"
)
r2_tx_power = _tx_power_selectbox(
"TX Power (R2)",
key="dante_tx_power_r2",
default=saved_settings.get('secondary', {}).get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
# Per-stream configuration for Radio 2
st.write("**Stream Configuration (Radio 2)**")
r2_streams = []
@@ -1559,7 +1471,6 @@ else:
r2_immediate_rendering = False
r2_presentation_delay_ms = 40
r2_qos_preset = 'Fast'
r2_tx_power = TX_POWER_DEFAULT
# Validate unique input devices for Network - Dante mode
if audio_mode == "Network - Dante":
@@ -1591,7 +1502,6 @@ else:
'immediate_rendering': r1_immediate_rendering,
'presentation_delay_ms': r1_presentation_delay_ms,
'qos_preset': r1_qos_preset,
'tx_power': r1_tx_power,
'dante_stereo_mode': dante_stereo_enabled,
'dante_stereo_left': dante_left_channel,
'dante_stereo_right': dante_right_channel,
@@ -1607,13 +1517,11 @@ else:
'immediate_rendering': r2_immediate_rendering if radio2_enabled else False,
'presentation_delay_ms': r2_presentation_delay_ms if radio2_enabled else 40000,
'qos_preset': r2_qos_preset if radio2_enabled else 'Fast',
'tx_power': r2_tx_power if radio2_enabled else TX_POWER_DEFAULT,
} if radio2_enabled else None
if audio_mode in ("USB", "Network"):
# USB/Network: single set of controls shared with the single channel
# Use saved settings if audio_mode matches, otherwise use defaults
quality_options = list(QUALITY_MAP.keys())
saved_audio_mode = saved_settings.get('audio_mode')
if saved_audio_mode in ("USB", "Network"):
# Map saved sampling rate to quality label
@@ -1633,6 +1541,8 @@ else:
# Use defaults when switching from another mode
default_quality = "Medium (24kHz)" if "Medium (24kHz)" in quality_options else quality_options[0]
saved_pwd = ''
quality_options = list(QUALITY_MAP.keys())
if default_quality not in quality_options:
default_quality = quality_options[0]
quality = st.selectbox(
@@ -1685,13 +1595,6 @@ else:
help="Fast: 2 retransmissions, lower latency. Robust: 4 retransmissions, better reliability."
)
tx_power = _tx_power_selectbox(
"TX Power",
key="usb_tx_power",
default=saved_settings.get('advertising_tx_power', TX_POWER_DEFAULT),
disabled=is_streaming,
)
stream_name = st.text_input(
"Channel Name",
value=default_name,
@@ -1823,22 +1726,12 @@ if start_stream:
bigs1 = []
for i in range(demo_cfg['streams']):
cfg_cls, lang = lang_cfgs[i % len(lang_cfgs)]
if demo_content == "1 kHz test tone":
source_file = f'../testdata/test_tone_1k_{int(q["rate"]/1000)}kHz_mono.lc3'
big_kwargs = {
'name': 'test tone',
'program_info': '1khz',
}
else:
source_file = f'../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.lc3'
big_kwargs = {}
bigs1.append(cfg_cls(
code=(stream_passwort.strip() or None),
audio_source=f'file:{source_file}',
audio_source=f'file:../testdata/wave_particle_5min_{lang}_{int(q["rate"]/1000)}kHz_mono.wav',
iso_que_len=32,
sampling_frequency=q['rate'],
octets_per_frame=q['octets'],
**big_kwargs,
))
max_per_mc = {48000: 1, 24000: 2, 16000: 3}
@@ -1855,7 +1748,6 @@ if start_stream:
immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=QOS_PRESET_MAP[qos_preset],
advertising_tx_power=tx_power_r1,
bigs=bigs1
)
config2 = None
@@ -1868,7 +1760,6 @@ if start_stream:
immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=QOS_PRESET_MAP[qos_preset],
advertising_tx_power=tx_power_r2,
bigs=bigs2
)
@@ -1901,9 +1792,7 @@ if start_stream:
q = QUALITY_MAP[cfg['quality']]
# Determine if this is stereo mode (only applicable for analog)
# Stereo is temporarily disabled in the UI; force mono regardless of any
# lingering saved 'stereo_mode' flag.
stereo_mode = False # cfg.get('stereo_mode', False)
stereo_mode = cfg.get('stereo_mode', False)
channels = 2 if stereo_mode else 1
return auracast_config.AuracastConfigGroup(
@@ -1914,7 +1803,6 @@ 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']],
advertising_tx_power=int(cfg.get('tx_power', TX_POWER_DEFAULT)),
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=[
@@ -1966,17 +1854,15 @@ if start_stream:
bigs = []
# Check if stereo mode is enabled for this radio
# Stereo is temporarily disabled in the UI; force mono regardless of any
# lingering saved 'dante_stereo_mode' flag or 'dante_stereo_' input device.
is_stereo_mode = False # bool(radio_cfg.get('dante_stereo_mode', False))
is_stereo_mode = bool(radio_cfg.get('dante_stereo_mode', False))
for i, stream in enumerate(radio_cfg['streams']):
if not stream.get('input_device'):
continue
# Check if this specific stream uses stereo (dante_stereo_X_Y device)
input_device = stream['input_device']
stream_is_stereo = False # is_stereo_mode or input_device.startswith('dante_stereo_')
stream_is_stereo = is_stereo_mode or input_device.startswith('dante_stereo_')
num_bis = 2 if stream_is_stereo else 1
num_channels = 2 if stream_is_stereo else 1
@@ -2004,7 +1890,6 @@ if start_stream:
immediate_rendering=bool(radio_cfg['immediate_rendering']),
presentation_delay_us=int(radio_cfg['presentation_delay_ms'] * 1000),
qos_config=QOS_PRESET_MAP[radio_cfg['qos_preset']],
advertising_tx_power=int(radio_cfg.get('tx_power', TX_POWER_DEFAULT)),
bigs=bigs
)
@@ -2040,7 +1925,6 @@ if start_stream:
immediate_rendering=immediate_rendering,
presentation_delay_us=int(presentation_delay_ms * 1000),
qos_config=QOS_PRESET_MAP[qos_preset],
advertising_tx_power=tx_power,
bigs=[
auracast_config.AuracastBigConfig(
code=(stream_passwort.strip() or None),
@@ -2089,9 +1973,9 @@ 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(
"Transmit LED",
"Blue LED on while transmitting",
value=led_enabled_current,
help="When enabled, the transmit LED lights up while the stream is active."
help="When enabled, the blue LED on GPIO pin 12 lights up while the stream is active."
)
if led_enabled != led_enabled_current:
try:
@@ -2100,19 +1984,6 @@ with st.expander("System control", expanded=False):
st.error(f"Failed to update LED setting: {e}")
st.rerun()
power_led_enabled_current = bool(saved_settings.get("power_led_enabled", False))
power_led_enabled = st.checkbox(
"Power LED",
value=power_led_enabled_current,
help="When enabled, the power LED stays lit."
)
if power_led_enabled != power_led_enabled_current:
try:
requests.post(f"{BACKEND_URL}/set_power_led_enabled", json={"power_led_enabled": power_led_enabled}, timeout=2)
except Exception as e:
st.error(f"Failed to update power LED setting: {e}")
st.rerun()
st.subheader("System temperatures")
temp_col1, temp_col2, temp_col3 = st.columns([1, 1, 1])
with temp_col1:
+9 -70
View File
@@ -29,77 +29,49 @@ from auracast.utils.sounddevice_utils import (
load_dotenv()
# Blue LED on GPIO pin 12 (BCM) turns on while transmitting.
# Power LED on GPIO pin 1 (BCM) a static indicator toggled from the UI.
# Both LEDs are active-high (drive HIGH = on, LOW = off).
# Blue LED on GPIO pin 12 (BCM) turns on while transmitting
LED_PIN = 12
POWER_LED_PIN = 1
try:
import RPi.GPIO as _GPIO
_GPIO.setmode(_GPIO.BCM)
_GPIO.setup(LED_PIN, _GPIO.OUT)
_GPIO.setup(POWER_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
_POWER_LED_ENABLED: bool = False # toggled via /set_power_led_enabled; off by default
_LED_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), 'led_settings.json')
def _load_led_settings() -> None:
global _LED_ENABLED, _POWER_LED_ENABLED
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))
_POWER_LED_ENABLED = bool(data.get('power_led_enabled', False))
except Exception:
_LED_ENABLED = True
_POWER_LED_ENABLED = False
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,
'power_led_enabled': _POWER_LED_ENABLED,
}, 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.HIGH)
_GPIO.output(LED_PIN, _GPIO.LOW)
except Exception:
pass
def _led_off():
if _GPIO_AVAILABLE:
try:
_GPIO.output(LED_PIN, _GPIO.LOW)
except Exception:
pass
def _is_any_streaming() -> bool:
"""True if either multicaster is currently streaming."""
for mc in (multicaster1, multicaster2):
try:
if mc is not None and mc.get_status().get('is_streaming'):
return True
except Exception:
pass
return False
def _apply_power_led():
"""Drive the power LED to match the persisted enable flag (active-high)."""
if _GPIO_AVAILABLE:
try:
_GPIO.output(POWER_LED_PIN, _GPIO.HIGH if _POWER_LED_ENABLED else _GPIO.LOW)
_GPIO.output(LED_PIN, _GPIO.HIGH)
except Exception:
pass
@@ -620,13 +592,6 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
demo_count = sum(1 for big in conf.bigs if isinstance(big.audio_source, str) and big.audio_source.startswith('file:'))
demo_rate = int(conf.auracast_sampling_rate_hz or 0)
demo_type = None
demo_sources = [
str(b.audio_source)
for b in conf.bigs
if isinstance(b.audio_source, str) and b.audio_source.startswith('file:')
]
is_demo_tone = bool(demo_sources) and all('test_tone_1k_' in src for src in demo_sources)
demo_content = '1 kHz test tone' if is_demo_tone else 'Program material'
if demo_count > 0 and demo_rate > 0:
if demo_rate in (48000, 24000, 16000):
demo_type = f"{demo_count} × {demo_rate//1000}kHz"
@@ -649,15 +614,13 @@ async def init_radio(transport: str, conf: auracast_config.AuracastConfigGroup,
'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),
'advertising_tx_power': getattr(conf, 'advertising_tx_power', 8),
'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],
'demo_total_streams': demo_count,
'demo_stream_type': demo_type,
'demo_content': demo_content,
'is_streaming': auto_started,
'demo_sources': demo_sources,
'demo_sources': [str(b.audio_source) for b in conf.bigs if isinstance(b.audio_source, str) and b.audio_source.startswith('file:')],
}
return mc, persisted
except HTTPException:
@@ -697,24 +660,10 @@ async def set_led_enabled(body: dict):
global _LED_ENABLED
_LED_ENABLED = bool(body.get("led_enabled", True))
_save_led_settings()
if _LED_ENABLED:
# Re-enabling mid-stream should light the LED immediately, not wait
# for the next stream start.
if _is_any_streaming():
_led_on()
else:
if not _LED_ENABLED:
_led_off()
return {"led_enabled": _LED_ENABLED}
@app.post("/set_power_led_enabled")
async def set_power_led_enabled(body: dict):
"""Enable or disable the power LED. Persisted across restarts."""
global _POWER_LED_ENABLED
_POWER_LED_ENABLED = bool(body.get("power_led_enabled", False))
_save_led_settings()
_apply_power_led()
return {"power_led_enabled": _POWER_LED_ENABLED}
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming on both multicasters via the BLE loop."""
@@ -805,7 +754,6 @@ async def get_status():
status["secondary"] = secondary
status["secondary_is_streaming"] = bool(secondary.get("is_streaming", False))
status["led_enabled"] = _LED_ENABLED
status["power_led_enabled"] = _POWER_LED_ENABLED
return status
@@ -846,12 +794,11 @@ async def _autostart_from_settings():
big_ids = settings.get('big_ids') or []
big_addrs = settings.get('big_random_addresses') or []
stream_password = settings.get('stream_password')
tx_power = int(settings.get('advertising_tx_power', 8))
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
log.info(
"[AUTOSTART][PRIMARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s tx_power=%+d dBm demo_sources=%s",
"[AUTOSTART][PRIMARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s demo_sources=%s",
previously_streaming,
audio_mode,
rate,
@@ -860,7 +807,6 @@ async def _autostart_from_settings():
saved_qos_preset,
immediate_rendering,
assisted_listening_stream,
tx_power,
(settings.get('demo_sources') or []),
)
@@ -910,7 +856,6 @@ 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,
advertising_tx_power=tx_power,
bigs=bigs,
)
# Set num_bis for stereo mode if needed
@@ -980,7 +925,6 @@ async def _autostart_from_settings():
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),
advertising_tx_power=tx_power,
bigs=bigs,
)
# Set num_bis for stereo mode if needed
@@ -1016,11 +960,10 @@ async def _autostart_from_settings():
big_ids = settings.get('big_ids') or []
big_addrs = settings.get('big_random_addresses') or []
stream_password = settings.get('stream_password')
tx_power = int(settings.get('advertising_tx_power', 8))
original_ts = settings.get('timestamp')
previously_streaming = bool(settings.get('is_streaming'))
log.info(
"[AUTOSTART][SECONDARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s tx_power=%+d dBm demo_sources=%s",
"[AUTOSTART][SECONDARY] loaded settings: previously_streaming=%s audio_mode=%s rate=%s octets=%s pres_delay=%s qos_preset=%s immediate_rendering=%s assisted_listening_stream=%s demo_sources=%s",
previously_streaming,
audio_mode,
rate,
@@ -1029,7 +972,6 @@ async def _autostart_from_settings():
saved_qos_preset,
immediate_rendering,
assisted_listening_stream,
tx_power,
(settings.get('demo_sources') or []),
)
if not previously_streaming:
@@ -1069,7 +1011,6 @@ 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,
advertising_tx_power=tx_power,
bigs=bigs,
)
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -1139,7 +1080,6 @@ async def _autostart_from_settings():
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),
advertising_tx_power=tx_power,
bigs=bigs,
)
conf.qos_config = QOS_PRESET_MAP.get(saved_qos_preset, QOS_PRESET_MAP["Fast"])
@@ -1189,7 +1129,6 @@ async def _startup_autostart_event():
# Hydrate settings cache once to avoid disk I/O during /status
_load_led_settings()
_apply_power_led()
_init_settings_cache_from_disk()
refresh_pw_cache()
# I2C init, ADC setup and the autostart task must run on the BLE loop so
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -21,12 +21,12 @@ def read_lc3_file(filepath):
logging.info('frame_duration %s', frame_duration)
logging.info('stream_length %s', stream_length)
chunks = []
lc3_bytes= b''
while True:
b = f_lc3.read(2)
if b == b'':
break
lc3_frame_size = struct.unpack('=H', b)[0]
chunks.append(f_lc3.read(lc3_frame_size))
lc3_bytes += f_lc3.read(lc3_frame_size)
return b''.join(chunks)
return lc3_bytes
+13051 -13095
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
# Provenance of src/openocd/merged.hex — generated by update_firmware.sh. Do not edit by hand.
firmware_commit: 52d034e58e42f15449b4d026e056ed99d10999d9
firmware_describe: 52d034e
firmware_branch: HEAD
preset: build_nrf54_radio0_radio1
ncs_version: v3.0.2
merged_hex_sha256: 4d7779191d08aaefe45f04c2689f3ecbe49688c8e78c7fa9a40550d545df8b17
exported_utc: 2026-07-13T12:02:55Z
-161
View File
@@ -1,161 +0,0 @@
#!/bin/bash
#
# update_firmware.sh — build (optional) and export the beacon firmware image.
#
# Copies the sysbuild `merged.hex` produced from the `firmware/` submodule to
# src/openocd/merged.hex (the committed artifact the RPi flashes via flash.sh)
# and records its provenance in src/openocd/merged.hex.version.
#
# This runs on a DEV MACHINE that has the nRF Connect SDK (~/ncs) — never on a
# beacon device. Devices only ever consume the committed merged.hex.
#
# Usage:
# src/openocd/update_firmware.sh [--build] [--preset NAME] [--ncs-version VER] [--force]
#
# (default) Export merged.hex from an existing build dir.
# --build Configure + build the sysbuild image from scratch (a true
# one-shot), then export. Uses the nRF Connect SDK via
# `nrfutil toolchain-manager launch` — no manual env setup.
# --preset NAME Preset in firmware/CMakePresets.json to build/export
# (default: build_nrf54_radio0_radio1). BOARD and the
# conf/overlay files are read straight from that preset.
# --ncs-version VER nRF Connect SDK version to build with (default: v3.0.2).
# West workspace is $HOME/ncs/VER unless $NCS_WORKSPACE set.
# --force Export even if the firmware submodule working tree is dirty.
#
# Example (rebuild the production image from the pinned firmware commit and export):
# git submodule update --init firmware
# src/openocd/update_firmware.sh --build
#
# Note: merged.hex is a *sysbuild* output. A plain `cmake --preset` configure
# only produces zephyr/zephyr.hex — this script deliberately refuses to fall
# back to that, so the exported artifact is always the real merged image.
set -euo pipefail
PRESET="build_nrf54_radio0_radio1"
NCS_VERSION="v3.0.2"
DO_BUILD=0
FORCE=0
usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit "${1:-1}"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--build) DO_BUILD=1 ;;
--preset) shift; PRESET="${1:?--preset needs a value}" ;;
--ncs-version) shift; NCS_VERSION="${1:?--ncs-version needs a value}" ;;
--force) FORCE=1 ;;
-h|--help) usage 0 ;;
*) echo "Unknown argument: $1" >&2; usage ;;
esac
shift
done
# Repo root = two levels up from this script (src/openocd/ -> repo root).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
FIRMWARE_DIR="$ROOT/firmware"
BUILD_DIR="$FIRMWARE_DIR/$PRESET"
SRC_HEX="$BUILD_DIR/merged.hex"
DEST_HEX="$ROOT/src/openocd/merged.hex"
VERSION_FILE="$ROOT/src/openocd/merged.hex.version"
die() { echo "[update_firmware] ERROR: $*" >&2; exit 1; }
# 1. Submodule present and populated?
[[ -f "$FIRMWARE_DIR/.git" || -d "$FIRMWARE_DIR/.git" ]] \
|| die "firmware submodule not initialised. Run: git submodule update --init firmware"
git -C "$FIRMWARE_DIR" rev-parse HEAD >/dev/null 2>&1 \
|| die "firmware submodule has no checkout. Run: git submodule update --init firmware"
# 2. Optional sysbuild build (configure + compile) via the NCS toolchain manager.
# BOARD and the conf/overlay files come straight from firmware/CMakePresets.json,
# so this stays in sync with the preset the nRF Connect VS Code extension uses.
if [[ "$DO_BUILD" == "1" ]]; then
command -v nrfutil >/dev/null 2>&1 \
|| die "'nrfutil' not found. Install the nRF Connect SDK toolchain manager first."
PRESETS_JSON="$FIRMWARE_DIR/CMakePresets.json"
[[ -f "$PRESETS_JSON" ]] || die "no CMakePresets.json in $FIRMWARE_DIR"
# Read BOARD / CONF_FILE / EXTRA_CONF_FILE / DTC_OVERLAY_FILE from the preset.
preset_vars="$(python3 - "$PRESETS_JSON" "$PRESET" <<'PY'
import json, sys
path, preset = sys.argv[1], sys.argv[2]
cfgs = {c["name"]: c for c in json.load(open(path)).get("configurePresets", [])}
if preset not in cfgs:
sys.exit(f"preset '{preset}' not found in {path}")
cv = cfgs[preset].get("cacheVariables", {})
def g(k):
v = cv.get(k)
return (v.get("value") if isinstance(v, dict) else v) or ""
for k in ("BOARD", "CONF_FILE", "EXTRA_CONF_FILE", "DTC_OVERLAY_FILE"):
print(f"{k}={g(k)}")
PY
)" || die "failed to parse preset '$PRESET' from CMakePresets.json"
# Import without eval — values (e.g. EXTRA_CONF_FILE) contain ';'.
PRESET_BOARD=""; PRESET_CONF_FILE=""; PRESET_EXTRA_CONF_FILE=""; PRESET_DTC_OVERLAY_FILE=""
while IFS='=' read -r _k _v; do
case "$_k" in
BOARD) PRESET_BOARD="$_v" ;;
CONF_FILE) PRESET_CONF_FILE="$_v" ;;
EXTRA_CONF_FILE) PRESET_EXTRA_CONF_FILE="$_v" ;;
DTC_OVERLAY_FILE) PRESET_DTC_OVERLAY_FILE="$_v" ;;
esac
done <<< "$preset_vars"
[[ -n "$PRESET_BOARD" ]] || die "preset '$PRESET' defines no BOARD"
WORKSPACE="${NCS_WORKSPACE:-$HOME/ncs/$NCS_VERSION}"
[[ -d "$WORKSPACE/.west" ]] \
|| die "no west workspace at '$WORKSPACE' (set \$NCS_WORKSPACE or pass --ncs-version)."
# Assemble the -D flags. CONF_FILE=prj.conf is west's default, so omit it then.
DFLAGS=()
[[ -n "$PRESET_CONF_FILE" && "$PRESET_CONF_FILE" != "prj.conf" ]] && DFLAGS+=( "-DCONF_FILE=$PRESET_CONF_FILE" )
[[ -n "$PRESET_EXTRA_CONF_FILE" ]] && DFLAGS+=( "-DEXTRA_CONF_FILE=$PRESET_EXTRA_CONF_FILE" )
[[ -n "$PRESET_DTC_OVERLAY_FILE" ]] && DFLAGS+=( "-DDTC_OVERLAY_FILE=$PRESET_DTC_OVERLAY_FILE" )
# Build the inner command with each token shell-quoted (conf lists contain ';').
inner="cd $(printf %q "$WORKSPACE") && west build --sysbuild -p auto"
inner+=" -b $(printf %q "$PRESET_BOARD") -d $(printf %q "$BUILD_DIR") $(printf %q "$FIRMWARE_DIR") --"
for f in "${DFLAGS[@]}"; do inner+=" $(printf %q "$f")"; done
echo "[update_firmware] Building $PRESET (NCS $NCS_VERSION, board $PRESET_BOARD) ..."
echo "[update_firmware] west build --sysbuild ${DFLAGS[*]}"
nrfutil toolchain-manager launch --ncs-version "$NCS_VERSION" -- bash -c "$inner" \
|| die "west build failed."
fi
# 3. Require the sysbuild artifact (no silent zephyr.hex fallback).
[[ -f "$SRC_HEX" ]] || die "no merged.hex at '$SRC_HEX'. Build '$PRESET' with sysbuild \
(pass --build once the dir is configured), or point --preset at the right build dir."
# 4. Clean-tree guard so the deployed hex maps to a real firmware commit.
FW_DESCRIBE="$(git -C "$FIRMWARE_DIR" describe --always --dirty --tags 2>/dev/null || git -C "$FIRMWARE_DIR" rev-parse --short HEAD)"
if [[ "$FW_DESCRIBE" == *-dirty && "$FORCE" != "1" ]]; then
die "firmware submodule working tree is dirty ($FW_DESCRIBE). Commit/stash it, or pass --force."
fi
# 5. Export.
cp "$SRC_HEX" "$DEST_HEX"
# 6. Provenance.
FW_COMMIT="$(git -C "$FIRMWARE_DIR" rev-parse HEAD)"
FW_BRANCH="$(git -C "$FIRMWARE_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'detached')"
HEX_SHA="$(sha256sum "$DEST_HEX" | awk '{print $1}')"
cat > "$VERSION_FILE" <<EOF
# Provenance of src/openocd/merged.hex — generated by update_firmware.sh. Do not edit by hand.
firmware_commit: $FW_COMMIT
firmware_describe: $FW_DESCRIBE
firmware_branch: $FW_BRANCH
preset: $PRESET
ncs_version: $NCS_VERSION
merged_hex_sha256: $HEX_SHA
exported_utc: $(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
echo "[update_firmware] Exported $SRC_HEX"
echo "[update_firmware] -> $DEST_HEX"
echo "[update_firmware] firmware: $FW_DESCRIBE ($FW_COMMIT)"
echo "[update_firmware] sha256: $HEX_SHA"
echo "[update_firmware] Now commit: git add src/openocd/merged.hex src/openocd/merged.hex.version firmware"