diff --git a/.gitignore b/.gitignore index 32b1bbf..b5cff35 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/docker-compose.yaml b/docker-compose.yaml index ec410af..5c08d1c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8632d7c..dcf581b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/auracast/auracast_config.py b/src/auracast/auracast_config.py index ad5ac03..a732393 100644 --- a/src/auracast/auracast_config.py +++ b/src/auracast/auracast_config.py @@ -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 \ No newline at end of file +class AuracastConfigGroup(AuracastGlobalConfig): + bigs: List[AuracastBigConfig] = [ + AuracastBigConfigDeu(), + ] diff --git a/src/auracast/multicast.py b/src/auracast/multicast.py index 67c041c..0bc8542 100644 --- a/src/auracast/multicast.py +++ b/src/auracast/multicast.py @@ -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() + + config = auracast_config.AuracastConfigGroup( + bigs = [ + auracast_config.AuracastBigConfigDeu(), + #auracast_config.AuracastBigConfigEng(), + #auracast_config.AuracastBigConfigFra(), + #auracast_config.AuracastBigConfigEs(), + #auracast_config.AuracastBigConfigIt(), + ] ) + # 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 - - global_conf.transport='serial:/dev/serial/by-id/usb-ZEPHYR_Zephyr_HCI_UART_sample_81BD14B8D71B5662-if00,115200,rtscts' #nrf52dongle hci_uart usb cdc - + 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 - - # TODO: How can we use other iso interval than 10ms ?(medium or low rel) ? - nrf53audio receiver repports I2S tx underrun - - bigs = [ - auracast_config.AuracastBigConfigDe(), - auracast_config.AuracastBigConfigEn(), - auracast_config.AuracastBigConfigFr(), - #auracast_config.AuracastBigConfigEs(), - #auracast_config.AuracastBigConfigIt(), - ] - for big in bigs: # TODO: encrypted streams are not working + 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 ) ) diff --git a/src/auracast/multicast_client.py b/src/auracast/multicast_client.py index b3f8aa3..035f73f 100644 --- a/src/auracast/multicast_client.py +++ b/src/auracast/multicast_client.py @@ -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 - print("Getting status:", get_status()) - print("Initializing server:", initialize()) - print("Getting status:", get_status()) - print("Sending audio:", send_audio()) - print("Getting status:", get_status()) + 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:", 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()) diff --git a/src/auracast/multicast_control.py b/src/auracast/multicast_control.py index 094268e..507eed3 100644 --- a/src/auracast/multicast_control.py +++ b/src/auracast/multicast_control.py @@ -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, ] diff --git a/src/auracast/multicast_server.py b/src/auracast/multicast_server.py index b0b1f41..e24b4f0 100644 --- a/src/auracast/multicast_server.py +++ b/src/auracast/multicast_server.py @@ -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 - -@app.route('/stop_audio', methods=['POST']) + raise HTTPException(status_code=500, detail=str(e)) + + +@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) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=5000) \ No newline at end of file