restrucuture_for_cloud (#4)

- update multicast_server
- modify config models
- add dockerfile and docker compose

Reviewed-on: https://gitea.pstruebi.xyz/auracaster/bumble-auracast/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
2025-03-19 13:00:13 +01:00
parent 948f3a1d90
commit 8ea7aeb412
8 changed files with 208 additions and 148 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ coverage/ # Coverage results after running tests with coverage tools
.dist-info/ # Wheel metadata (use poetry build to handle this)
*.egg-info/ # Egg info directory (automatically created by pip)
auracast.egg-info/
/build
# Ignore these file types and extensions
*.pyc # Compiled Python files (.pyc, .pyo are automatically ignored by git)

View File

@@ -1,19 +1,23 @@
services:
app:
multicaster:
privileged: true # Grants full access to all devices (needed for serial access)
restart: unless-stopped
ports:
- "5000:5000"
build:
context: .
dockerfile: Dockerfile
ssh:
- default=~/.ssh/id_ed25519 #lappi
#- default=~/.ssh/id_rsa #raspi
devices:
- /dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00
volumes:
- "/dev/serial:/dev/serial"
#devices:
# - /dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00
environment:
LOG_LEVEL: INFO
# - DOCKER_BUILDKIT=1 # Enable BuildKit (can also be passed during build)
#command: python ./auracast/multicast_server.py #devserver
command: python ./auracast/multicast.py # continously streaming test app
command: python ./auracast/multicast_server.py
#command: python ./auracast/multicast.py # continously streaming test app
# use docker compose up --build

View File

@@ -8,8 +8,10 @@ dependencies = [
"lc3 @ git+ssh://git@ssh.pstruebi.xyz:222/auracaster/liblc3.git@7558637303106c7ea971e7bb8cedf379d3e08bcc",
"sounddevice",
"aioconsole",
"quart == 0.20.0",
"pydantic"
"fastapi==0.115.11",
"uvicorn==0.34.0",
"pydantic",
"aiohttp==3.9.3"
]
[project.optional-dependencies]

View File

@@ -1,3 +1,4 @@
from typing import List
from pydantic import BaseModel
# Define some base to hold the relevant parameters
@@ -32,8 +33,8 @@ 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
manufacturer_data: tuple[int, bytes] |None = None # TODO:pydantic does not support bytes serialization
# TODO:pydantic does not support bytes serialization - use .hex and np.fromhex()
manufacturer_data: tuple[int, bytes] | tuple[None, None] = (None, None)
# "Audio input. "
# "'device' -> use the host's default sound input device, "
@@ -54,7 +55,7 @@ class AuracastBigConfig(BaseModel):
precode_wav: bool = False
iso_que_len: int = 64
class AuracastBigConfigDe(AuracastBigConfig):
class AuracastBigConfigDeu(AuracastBigConfig):
id: int = 12
random_address: str = 'F1:F1:F2:F3:F4:F5'
name: str = 'Broadcast0'
@@ -62,7 +63,7 @@ class AuracastBigConfigDe(AuracastBigConfig):
program_info: str = 'Announcements German'
audio_source: str = 'file:./testdata/announcement_de.wav'
class AuracastBigConfigEn(AuracastBigConfig):
class AuracastBigConfigEng(AuracastBigConfig):
id: int = 123
random_address: str = 'F2:F1:F2:F3:F4:F5'
name: str = 'Broadcast1'
@@ -70,7 +71,7 @@ class AuracastBigConfigEn(AuracastBigConfig):
program_info: str = 'Announcements English'
audio_source: str = 'file:./testdata/announcement_en.wav'
class AuracastBigConfigFr(AuracastBigConfig):
class AuracastBigConfigFra(AuracastBigConfig):
id: int = 1234
random_address: str = 'F3:F1:F2:F3:F4:F5'
name: str = 'Broadcast2'
@@ -78,7 +79,7 @@ class AuracastBigConfigFr(AuracastBigConfig):
program_info: str = 'Announcements French'
audio_source: str = 'file:./testdata/announcement_fr.wav'
class AuracastBigConfigEs(AuracastBigConfig):
class AuracastBigConfigSpa(AuracastBigConfig):
id: int =12345
random_address: str = 'F4:F1:F2:F3:F4:F5'
name: str = 'Broadcast3'
@@ -86,7 +87,7 @@ class AuracastBigConfigEs(AuracastBigConfig):
program_info: str = 'Announcements Spanish'
audio_source: str = 'file:./testdata/announcement_es.wav'
class AuracastBigConfigIt(AuracastBigConfig):
class AuracastBigConfigIta(AuracastBigConfig):
id: int =1234567
random_address: str = 'F5:F1:F2:F3:F4:F5'
name: str = 'Broadcast4'
@@ -95,4 +96,7 @@ class AuracastBigConfigIt(AuracastBigConfig):
audio_source: str = 'file:./testdata/announcement_it.wav'
# TODO: could be best to merge all in just one CONFIG class and give every language an enable parameter
class AuracastConfigGroup(AuracastGlobalConfig):
bigs: List[AuracastBigConfig] = [
AuracastBigConfigDeu(),
]

View File

@@ -23,7 +23,7 @@ import logging
import wave
import itertools
import struct
from typing import cast, Any, AsyncGenerator, Coroutine, Dict, Optional, Tuple, List
from typing import cast, Any, AsyncGenerator, Coroutine, List
import itertools
try:
@@ -186,7 +186,7 @@ async def init_broadcast(
logger.info('Setup Advertising')
advertising_manufacturer_data = (
b''
if global_config.manufacturer_data is None
if global_config.manufacturer_data == (None, None)
else bytes(
core.AdvertisingData(
[
@@ -502,31 +502,27 @@ if __name__ == "__main__":
)
os.chdir(os.path.dirname(__file__))
global_conf = auracast_config.AuracastGlobalConfig(
qos_config=auracast_config.AuracastQosHigh()
)
#global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,1000000,rtscts' # transport for nrf52 dongle
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001050076061-if02,1000000,rtscts' # transport for nrf53dk
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001057705357-if02,1000000,rtscts' # transport for nrf54l15dk
global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
# global_conf.transport='usb:2fe3:000b' #nrf52dongle hci_usb # TODO: iso packet over usb not supported
# TODO: How can we use other iso interval than 10ms ?(medium or low rel) ? - nrf53audio receiver repports I2S tx underrun
config = auracast_config.AuracastConfigGroup(
bigs = [
auracast_config.AuracastBigConfigDe(),
auracast_config.AuracastBigConfigEn(),
auracast_config.AuracastBigConfigFr(),
auracast_config.AuracastBigConfigDeu(),
#auracast_config.AuracastBigConfigEng(),
#auracast_config.AuracastBigConfigFra(),
#auracast_config.AuracastBigConfigEs(),
#auracast_config.AuracastBigConfigIt(),
]
for big in bigs: # TODO: encrypted streams are not working
)
# TODO: How can we use other iso interval than 10ms ?(medium or low rel) ? - nrf53audio receiver repports I2S tx underrun
config.qos_config=auracast_config.AuracastQosHigh()
#global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,1000000,rtscts' # transport for nrf52 dongle
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001050076061-if02,1000000,rtscts' # transport for nrf53dk
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001057705357-if02,1000000,rtscts' # transport for nrf54l15dk
config.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_95A087EADB030B24-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
# global_conf.transport='usb:2fe3:000b' #nrf52dongle hci_usb # TODO: iso packet over usb not supported
for big in config.bigs: # TODO: encrypted streams are not working
#big.code = 'ff'*16 # returns hci/HCI_ENCRYPTION_MODE_NOT_ACCEPTABLE_ERROR
#big.code = '78 e5 dc f1 34 ab 42 bf c1 92 ef dd 3a fd 67 ae'
big.precode_wav = True
@@ -539,14 +535,15 @@ if __name__ == "__main__":
# TODO: find the bottleneck - probably airtime
# TODO: test encrypted streams
global_conf.auracast_sampling_rate_hz = 16000
global_conf.octets_per_frame = 40 # 32kbps@16kHz
#global_conf.debug = True
config.auracast_sampling_rate_hz = 16000
config.octets_per_frame = 40 # 32kbps@16kHz
#config.debug = True
run_async(
broadcast(
global_conf,
bigs
config,
config.bigs
)
)

View File

@@ -1,36 +1,87 @@
import requests
import aiohttp
import asyncio
import base64
from typing import Optional, Dict
from auracast import auracast_config
from auracast.utils.read_lc3_file import read_lc3_file
from auracast.auracast_config import AuracastConfigGroup
BASE_URL = "http://127.0.0.1:5000" # Adjust based on your actual API URL
BASE_URL = "http://127.0.0.1:5000" # Default base URL
def initialize():
response = requests.post(f"{BASE_URL}/init")
return response.json()
def shutdown():
response = requests.post(f"{BASE_URL}/shutdown")
return response.json()
async def init(request_data : AuracastConfigGroup, base_url: Optional[str] = None):
url = base_url if base_url is not None else BASE_URL
async with aiohttp.ClientSession() as session:
async with session.post(f"{url}/init", json=request_data.model_dump()) as response:
if response.status != 200:
raise Exception(f"Error: {response.status}, {await response.text()}")
return await response.json()
def stop_audio():
response = requests.post(f"{BASE_URL}/stop_audio")
return response.json()
def send_audio(data_dict):
response = requests.post(f"{BASE_URL}/stream_lc3", json=data_dict)
return response.json()
async def send_audio(data_dict: Dict[str, str], base_url: Optional[str] = None):
# TODO: use base64 encoding
for language, lc3_data in data_dict.items():
data_dict[language] =lc3_data.decode('latin-1')
url = base_url if base_url is not None else BASE_URL
async with aiohttp.ClientSession() as session:
async with session.post(f"{url}/stream_lc3", json=data_dict) as response:
if response.status != 200:
raise Exception(f"Error: {response.status}, {await response.text()}")
return await response.json()
async def shutdown(base_url: Optional[str] = None):
url = base_url if base_url is not None else BASE_URL
async with aiohttp.ClientSession() as session:
async with session.post(f"{url}/shutdown") as response:
if response.status != 200:
raise Exception(f"Error: {response.status}, {await response.text()}")
return await response.json()
async def stop_audio(base_url: Optional[str] = None):
url = base_url if base_url is not None else BASE_URL
async with aiohttp.ClientSession() as session:
async with session.post(f"{url}/stop_audio") as response:
if response.status != 200:
raise Exception(f"Error: {response.status}, {await response.text()}")
return await response.json()
async def get_status(base_url: Optional[str] = None):
url = base_url if base_url is not None else BASE_URL
async with aiohttp.ClientSession() as session:
async with session.get(f"{url}/status") as response:
if response.status != 200:
raise Exception(f"Error: {response.status}, {await response.text()}")
return await response.json()
def get_status():
response = requests.get(f"{BASE_URL}/status")
return response.json()
if __name__ == "__main__":
test_audio_data = { # TODO: investigate further whats the best way to actually transfer the data
"broadcast_deu": read_lc3_file('src/auracast/testdata/announcement_de_10_16_32.lc3').decode('latin-1'),
"broadcast_eng": read_lc3_file('src/auracast/testdata/announcement_en_10_16_32.lc3').decode('latin-1')
import asyncio
async def main():
config = AuracastConfigGroup(
auracast_config.AuracastBigConfigDeu()
)
config.transport = 'serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts'
# Initialize language-based configurations
for conf in config.bigs:
conf.loop = False
audio_data = { # TODO: investigate further whats the best way to actually transfer the data
"deu": read_lc3_file('src/auracast/testdata/announcement_de_10_16_32.lc3').decode('latin-1'),
#"eng": read_lc3_file('src/auracast/testdata/announcement_en_10_16_32.lc3').decode('latin-1'),
#"fra": read_lc3_file('src/auracast/testdata/announcement_fr_10_16_32.lc3').decode('latin-1'),
}
print("Getting status:", get_status())
print("Initializing server:", initialize())
print("Getting status:", get_status())
print("Sending audio:", send_audio())
print("Getting status:", get_status())
print("Getting status:", await get_status())
print("Initializing server:", await init(config))
print("Getting status:", await get_status())
print("Sending audio:", await send_audio(audio_data))
print("Getting status:", await get_status())
asyncio.run(main())

View File

@@ -128,9 +128,9 @@ async def main():
global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
big_conf = [
auracast_config.AuracastBigConfigDe(),
auracast_config.AuracastBigConfigEn(),
auracast_config.AuracastBigConfigFr(),
auracast_config.AuracastBigConfigDeu(),
auracast_config.AuracastBigConfigEng(),
auracast_config.AuracastBigConfigFra(),
#auracast_config.broadcast_es,
#auracast_config.broadcast_it,
]

View File

@@ -1,103 +1,104 @@
import glob
import logging as log
from dataclasses import asdict
from quart import Quart, request, jsonify
from auracast import multicast_control
from auracast import auracast_config
from fastapi import FastAPI, HTTPException
from auracast import multicast_control, auracast_config
app = Quart(__name__)
app = FastAPI()
# Initialize global configuration
global_config_group = auracast_config.AuracastConfigGroup()
# Create multicast controller
multicaster: multicast_control.Multicaster | None = None
# TODO: redo this with fastapi, transfer whole radio config on init
# Initialize the multicaster instance globally
global_conf = auracast_config.AuracastGlobalConfig()
#global_conf.transport='serial:/dev/serial/by-id/usb-SEGGER_J-Link_001057705357-if02,1000000,rtscts' # transport for nrf54l15dk
global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc
big_conf = { # TODO: use another dataclass for this to be able to iterate over the names
'deu': auracast_config.AuracastBigConfigDe(),
'eng': auracast_config.AuracastBigConfigEn(),
'fra': auracast_config.AuracastBigConfigFr(),
#auracast_config.broadcast_es,
#auracast_config.broadcast_it,
}
for conf in big_conf.values():
conf.loop = False
multicaster = multicast_control.Multicaster(
global_conf,
list(big_conf.values()),
)
@app.route('/init', methods=['POST'])
async def initialize():
@app.post("/init")
async def initialize(conf: auracast_config.AuracastConfigGroup):
"""Initializes the broadcasters."""
#data = await request.json
#global_conf = auracast_config.AuracastGlobalConfig.from_dict(data['global_config'])
#stream_configs = [auracast_config.AuracastBigConfig.from_dict(big) for big in data['big_configs']]
global global_config_group
global multicaster
try:
await multicaster.init_broadcast()
return jsonify({"status": "initialized"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
if conf.transport == 'auto':
serial_devices = glob.glob('/dev/serial/by-id/*')
log.info('Found serial devices: %s', serial_devices)
for device in serial_devices:
if 'usb-ZEPHYR_Zephyr_HCI_UART_sample' in device:
log.info('Using: %s', device)
conf.transport = f'serial:{device},115200,rtscts'
break
@app.route('/shutdown', methods=['POST'])
async def stop():
# check again if transport is still auto
if conf.transport == 'auto':
HTTPException(status_code=500, detail='No suitable transport found.')
# initialize the streams dict
global_config_group = conf
log.info(
'Initializing multicaster with config:\n %s', conf.model_dump_json(indent=2)
)
multicaster = multicast_control.Multicaster(
conf,
conf.bigs,
)
await multicaster.init_broadcast()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/stream_lc3")
async def send_audio(audio_data: dict[str, str]):
"""Streams pre-coded LC3 audio."""
if multicaster is None:
raise HTTPException(status_code=500, detail='Auracast endpoint was never intialized')
try:
for big in global_config_group.bigs:
assert big.language in audio_data, HTTPException(status_code=500, detail='language len missmatch')
log.info('Received a send audio request for %s', big.language)
big.audio_source = audio_data[big.language].encode('latin-1') # TODO: use base64 encoding
multicaster.big_conf = global_config_group.bigs
multicaster.start_streaming()
return {"status": "audio_sent"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/shutdown")
async def shutdown():
"""Stops broadcasting."""
try:
await multicaster.reset()
return jsonify({"status": "stopped"}), 200
return {"status": "stopped"}
except Exception as e:
return jsonify({"error": str(e)}), 500
raise HTTPException(status_code=500, detail=str(e))
@app.route('/stop_audio', methods=['POST'])
@app.post("/stop_audio")
async def stop_audio():
"""Stops streaming."""
try:
multicaster.stop_streaming()
return jsonify({"status": "stopped"}), 200
return {"status": "stopped"}
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/stream_lc3', methods=['POST'])
async def send_audio():
"""Streams pre-coded LC3 audio.
# post data in the format
{
broadcast_deu: b''
broadcast_fra: b''
}
"""
post_data = await request.json
try:
for key, val in big_conf.items(): #TODO: loop over caster.big_conf directly
if key in post_data:
log.info('Received a send audio request for %s', key)
val.audio_source = post_data[key].encode('latin-1')
else:
val.audio_source = b''
multicaster.big_conf = list(big_conf.values())
multicaster.start_streaming()
return jsonify({"status": "audio_sent"}), 200
except Exception as e:
return jsonify({"error": str(e)}), 500
# TODO: Also a queue should be implemented - probably as its own endpoint,
raise HTTPException(status_code=500, detail=str(e))
@app.route('/status', methods=['GET'])
@app.get("/status")
async def get_status():
"""Gets the current status of the multicaster."""
status = multicaster.get_status()
# TODO: also get queue status, announcements, samples etc.
return jsonify({"status": status}), 200
if multicaster:
return multicaster.get_status()
else:
return {
'is_initialized': False,
'is_streaming': False,
}
if __name__ == '__main__':
import uvicorn
log.basicConfig(
level=log.INFO,
format='%(module)s.py:%(lineno)d %(levelname)s: %(message)s'
)
app.run(host='0.0.0.0', port=5000, debug=True)
uvicorn.run(app, host="0.0.0.0", port=5000)