from fastapi import HTTPException, APIRouter import subprocess import os import signal import json import asyncio from internal.logger import create_logger from models.models import ( ConfigGenerator, DecodeMode, ChannelConfig, DeviceConfig, TrunkingConfig, TrunkingChannelConfig, TerminalConfig, MetadataConfig, MetadataStreamConfig ) from internal.op25_config_utls import ( save_talkgroup_tags, save_whitelist, del_none_in_dict, get_current_system_from_config, activate_config_from_library, save_config_to_library, scan_local_library ) from internal.liquidsoap_config_utils import generate_liquid_script LOGGER = create_logger(__name__) # Global process tracker op25_process = None OP25_PATH = "/op25/op25/gr-op25_repeater/apps/" OP25_SCRIPT = "run_multi-rx_service.sh" async def stop_op25_logic(): """ Kills the OP25 process group to ensure sub-processes like Liquidsoap are also terminated. """ global op25_process if op25_process and op25_process.poll() is None: try: # Kill the entire process group os.killpg(os.getpgid(op25_process.pid), signal.SIGTERM) op25_process = None LOGGER.info("OP25 Process group stopped successfully") return True except Exception as e: LOGGER.error(f"Error stopping OP25 process group: {e}") return False return False async def start_op25_logic(): """ Starts the OP25 shell script as a new process group. """ global op25_process if op25_process is None or op25_process.poll() is not None: try: op25_process = subprocess.Popen( ["/bin/bash", os.path.join(OP25_PATH, OP25_SCRIPT)], preexec_fn=os.setsid, # Create a new process group cwd=OP25_PATH ) LOGGER.info(f"OP25 started with PID: {op25_process.pid}") return True except Exception as e: LOGGER.error(f"Failed to start OP25: {e}") return False return False def build_op25_config(generator: ConfigGenerator) -> dict: if generator.type == DecodeMode.P25: channels = [ChannelConfig( name=generator.systemName, trunking_sysname=generator.systemName, enable_analog="off", demod_type="cqpsk", cqpsk_tracking=True, filter_type="rc", meta_stream_name="stream_0" )] devices = [DeviceConfig()] trunking = TrunkingConfig( module="tk_p25.py", chans=[TrunkingChannelConfig( sysname=generator.systemName, control_channel_list=','.join(generator.channels), tagsFile="/configs/active.cfg.tags.tsv", whitelist="/configs/active.cfg.whitelist.tsv" )] ) metadata = MetadataConfig( streams=[ MetadataStreamConfig( stream_name="stream_0", icecastServerAddress = f"{generator.icecastConfig.icecast_host}:{generator.icecastConfig.icecast_port}", icecastMountpoint = generator.icecastConfig.icecast_mountpoint, icecastPass = generator.icecastConfig.icecast_password ) ] ) terminal = TerminalConfig() return { "channels": [channel.dict() for channel in channels], "devices": [device.dict() for device in devices], "trunking": trunking.dict(), "metadata": metadata.dict(), "terminal": terminal.dict() } elif generator.type == DecodeMode.ANALOG: analog_config = generator.config channels = [ChannelConfig( channelName=analog_config.systemName, enableAnalog="on", demodType="fsk4", frequency=analog_config.frequency, filterType="widepulse", nbfmSquelch=analog_config.nbfmSquelch )] devices = [DeviceConfig(gain="LNA:32")] return { "channels": [channel.dict() for channel in channels], "devices": [device.dict() for device in devices] } else: raise HTTPException(status_code=400, detail="Invalid decode mode") def save_library_sidecars(system_name: str, generator: ConfigGenerator): if generator.type == DecodeMode.P25: prefix = system_name if prefix.endswith(".json"): prefix = prefix[:-5] save_talkgroup_tags(generator.tags, prefix) save_whitelist(generator.whitelist, prefix) def create_op25_router(): router = APIRouter() @router.post("/start") async def start_op25(): if await start_op25_logic(): return {"status": "OP25 started"} raise HTTPException(status_code=500, detail="Failed to start OP25 (Check logs for hardware/config errors)") @router.post("/stop") async def stop_op25(): if await stop_op25_logic(): return {"status": "OP25 stopped"} return {"status": "OP25 was not running"} @router.get("/status") async def get_status(): is_running = op25_process is not None and op25_process.poll() is None return { "node_id": os.getenv("NODE_ID", "standalone-node"), "is_running": is_running, "pid": op25_process.pid if is_running else None, "active_system": get_current_system_from_config() if is_running else None } @router.post("/set_active_config") async def set_active_config(generator: ConfigGenerator, restart: bool = True, save_to_library_name: str = None): """ Takes a complex config model, generates the JSON, saves it to active.cfg.json, and optionally restarts the radio. """ try: # 1. Build the configuration dictionary config_dict = build_op25_config(generator) final_json = del_none_in_dict(config_dict) # 2. Handle Storage and Activation if save_to_library_name: # Save to library save_config_to_library(save_to_library_name, final_json) save_library_sidecars(save_to_library_name, generator) # Activate from library (Copies json + sidecars) if not activate_config_from_library(save_to_library_name): raise HTTPException(status_code=500, detail="Failed to activate saved configuration") else: # Save directly to active with open('/configs/active.cfg.json', 'w') as f: json.dump(final_json, f, indent=2) if generator.type == DecodeMode.P25: save_talkgroup_tags(generator.tags) save_whitelist(generator.whitelist) # 3. Generate Liquidsoap Script (Always required for active P25 session) if generator.type == DecodeMode.P25: generate_liquid_script(generator.icecastConfig) LOGGER.info("Saved new configuration to active.cfg.json") # 4. Handle Lifecycle if restart: LOGGER.info("Restarting OP25 to apply new config...") await stop_op25_logic() await asyncio.sleep(1.5) # Allow sockets to clear await start_op25_logic() return {"message": "Active configuration updated", "radio_restarted": restart} except Exception as e: LOGGER.error(f"Config export failed: {e}") raise HTTPException(status_code=500, detail=f"Configuration error: {str(e)}") @router.post("/load_from_library") async def load_from_library(system_name: str): """ Swaps the active config with a pre-existing file in the /configs library. """ if activate_config_from_library(system_name): await stop_op25_logic() await asyncio.sleep(1.5) await start_op25_logic() return {"status": f"Loaded and started library config: {system_name}"} raise HTTPException(status_code=404, detail=f"Config '{system_name}' not found in library volume") @router.post("/save_to_library") async def save_to_library(system_name: str, config: ConfigGenerator): """ Directly saves a JSON configuration to the library. """ try: config_dict = build_op25_config(config) final_json = del_none_in_dict(config_dict) if save_config_to_library(system_name, final_json): save_library_sidecars(system_name, config) return {"status": f"Config saved as {system_name}"} raise HTTPException(status_code=500, detail="Failed to save configuration") except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/library") async def get_library(): """ Returns a list of all saved configurations in the library. """ return scan_local_library() return router