# Plugins Los **plugins** drop-in amplían el peer server sin modificar el código del núcleo. El framework está en `src/adn_server/application/plugins/`; cada plugin es un directorio bajo `plugins//` cargado en tiempo de ejecución. El repositorio incluye un skeleton de referencia: `plugins/example/` (deshabilitado por defecto). --- ## Estructura de directorios ``` plugins// config.yaml # enabled: true|false (+ opciones) plugin/ __init__.py # debe exportar create_plugin() domain/ # lógica pura, sin I/O application/ # casos de uso, adaptador ServerPlugin infrastructure/ # archivos, HTTP, pools de hilos tests/ ``` Activa un plugin en su `config.yaml` y recarga el servidor: ```bash systemctl reload adn-server # SIGHUP — reescanea plugins/ ``` --- ## Configuración del servidor (`PLUGINS`) Bloque opcional en **`adn-server.yaml`** (no está en `adn-server.example.yaml`): ```yaml PLUGINS: directory: plugins # por defecto: /plugins master_kill: false # true → descarga todos los plugins overrides: my-plugin: some_key: value # se fusiona con la config del plugin ``` | Clave | Función | |-------|---------| | `directory` | Ruta absoluta o relativa al project root | | `master_kill` | Desactivación de emergencia — no carga plugins | | `overrides` | Parches por plugin sin editar `plugins//config.yaml` | Con **SIGHUP**, `PluginManager.rescan()` carga plugins nuevos, descarga los eliminados y llama `on_reload()` si cambió `config.yaml`. ### Claves reservadas en `config.yaml` El loader las elimina antes de pasar la config a `on_load` / `on_reload`: | Clave | Función | |-------|---------| | `enabled` | Debe ser `true` para cargar el plugin | | `depends_on` | Lista de nombres — orden topológico de carga | | `hot_reload_seconds` | Reservado para uso futuro | --- ## Contrato `ServerPlugin` Definido en `src/adn_server/application/plugins/domain/protocol.py`: | Método | Hilo | Función | |--------|------|---------| | `name: str` | — | Identificador (nombre del directorio) | | `on_load(bus, config, server_ctx)` | reactor | Leer config; suscribirse al bus si hace falta | | `on_event(event)` | **reactor** | Manejar eventos — **O(1), sin I/O bloqueante** | | `on_reload(config)` | reactor | Hot-reload opcional tras cambio de `config.yaml` | | `on_shutdown()` | reactor | Vaciar buffers; parar workers | Punto de entrada: ```python # plugins//plugin/__init__.py def create_plugin() -> ServerPlugin: return MyPlugin() ``` --- ## `ServerContext` Se pasa a `on_load` como `server_ctx` (`application/plugins/application/context.py`): | Campo | Uso | |-------|-----| | `config` | Dict de configuración completa del servidor | | `project_root` | Ruta de instalación | | `defer_to_thread(fn, *args)` | Ejecutar I/O bloqueante fuera del reactor | | `call_from_reactor(fn, *args)` | Programar callback en el hilo del reactor | | `call_later(delay_s, fn, *args)` | Temporizador del reactor | **Patrón:** solo despachar en `on_event`; usar `defer_to_thread` para archivos, HTTP o CPU intensiva. --- ## Bus de eventos `PluginBus` (`application/plugins/application/bus.py`) entrega eventos al `on_event` de cada plugin cargado. - Los eventos se emiten **después del forward** de voz/datos (`emit_deferred` — siguiente tick del reactor). - Excepciones no capturadas incrementan un contador; tras repetir fallos el plugin se **deshabilita** y se llama `on_shutdown()` (circuit breaker). - `bus.subscribe(handler)` existe para handlers internos; los plugins suelen usar solo `on_event`. --- ## Tipos de evento Dataclasses puras en `application/plugins/domain/events.py`. Importar en el plugin: ```python from adn_server.application.plugins.domain.events import ( VoiceCallStart, VoiceCallFrame, VoiceCallEnd, UnitDataStart, UnitDataFrame, UnitDataEnd, ) ``` | Evento | Cuándo | |--------|--------| | `VoiceCallStart` | Inicio de llamada de voz (grupo o privada) | | `VoiceCallFrame` | Un frame AMBE (`dmrpkt`, `frame_type`, `dtype_vseq`) | | `VoiceCallEnd` | Fin de llamada (`duration_s`, `frame_count`) | | `UnitDataStart` | Inicio de sesión unit-data | | `UnitDataFrame` | Un frame de datos (`raw_data`, `data_label`, `seq`, `bits`, …) | | `UnitDataEnd` | Fin de sesión unit-data (`duration_s`, `packet_count`) | Cada evento incluye **`CallLegContext`** (`event.context`): | Campo | Significado | |-------|-------------| | `call_family` | `"GROUP"` o `"PRIVATE"` | | `direction` | `"RX"` o `"TX"` | | `origin_system` | Nombre del sistema lógico (pata del bridge) | | `system_mode` | `MASTER`, `PEER` u `OPENBRIDGE` | | `peer_id`, `src_id`, `dst_id`, `slot`, `stream_id` | Identificadores DMR | | `server_id` | Id de servidor para informes | | `is_synthetic`, `is_proxy_ingress` | Flags sintético / proxy | | `pkt_time` | Timestamp Unix | | `forwarded_systems` | Sistemas a los que se reenvió esta pata | | `obp_*`, `ber`, `rssi` | Metadatos OpenBridge / RF si aplica | | `extra` | Dict adicional (p. ej. talker alias al finalizar) | Usa `event.context.to_metadata_dict()` para salida JSON. Los eventos provienen de `VoicePluginBridge` y `DataPluginBridge`, enganchados al routing tras el forward. --- ## Clean architecture en plugins Misma regla de dependencias hacia dentro que el núcleo ([Arquitectura](../development/architecture.md)): ```mermaid flowchart TD subgraph plugin_pkg ["plugins/my-plugin/plugin/"] impl["application/plugin_impl.py"] uc["application/*_use_case.py"] dom["domain/"] inf["infrastructure/"] impl --> uc --> dom inf --> uc end coreEvents["adn_server.application.plugins.domain.events"] impl --> coreEvents ``` | Capa | Responsabilidad | |------|-----------------| | `plugin/__init__.py` | Solo factory — `create_plugin()` | | `application/plugin_impl.py` | Adaptador `ServerPlugin`: `isinstance`, delegar a use cases | | `application/` | Orquestación (casos de uso, estado de sesión) | | `domain/` | Tipos y reglas puras — sin Twisted, archivos ni sockets | | `infrastructure/` | Writers, clientes HTTP, pools — usa `defer_to_thread` | --- ## Usando el plugin de ejemplo `plugins/example/` es un plugin funcional, deshabilitado por defecto — actívalo para ver el framework en acción, o copia su estructura como punto de partida para el tuyo ([Clean architecture en plugins](#clean-architecture-en-plugins) más arriba). ### Qué hace - Loguea una línea `DEBUG` `(EXAMPLE) …` por cada evento del bus (`VoiceCallStart`/`Frame`/`End`, `UnitDataStart`/`Frame`/`End`). - Trackea cada sesión, identificada por `(origin_system, stream_id)`, y al `VoiceCallEnd` / `UnitDataEnd` escribe un JSON con metadata de la llamada, duración y cantidad de frames/paquetes. - Sin llamadas HTTP, sin filtros, sin secretos — seguro de activar tal cual (`plugins/example/plugin/application/plugin_impl.py`). ### Activarlo ```yaml # plugins/example/config.yaml enabled: true output_dir: example-events # relativo al project root; se crea en la primera escritura ``` ```bash systemctl reload adn-server # SIGHUP — PluginManager reescanea plugins/ ``` ### Opciones de configuración | Clave | Default | Función | |-------|---------|---------| | `enabled` | `false` | Debe ser `true` para cargar el plugin | | `output_dir` | `example-events` | Dónde se escriben los JSON de sesión, relativo al project root del servidor | ### Salida Un archivo por sesión: `/.json` (`stream_id` como entero plano, no hex), con formato indentado y claves ordenadas. La escritura corre fuera del hilo del reactor vía `defer_to_thread`, así que nunca bloquea el manejo de llamadas. Ejemplo — una llamada de voz grupal en `SYSTEM` que terminó tras 2.16s, retransmitida a `OBP-USA`: ```json { "call_family": "GROUP", "direction": "RX", "dst_id": 91, "duration_s": 2.16, "ended_at": "2026-09-18T21:05:11.532000Z", "event_kind": "voice", "forwarded_systems": ["OBP-USA"], "frame_count": 36, "is_proxy_ingress": false, "is_synthetic": false, "origin_system": "SYSTEM", "peer_id": 312000, "pkt_time": 1758229511.532, "server_id": 73010, "slot": 1, "src_id": 7300391, "started_at": "2026-09-18T21:05:09.372000Z", "stream_id": 1234567890, "system_mode": "MASTER" } ``` Para sesiones unit-data, `event_kind` es `"unit_data"` y el campo de conteo es `packet_count` en vez de `frame_count`. Las patas que cruzaron un OpenBridge también llevan `obp_source_server_id`, `obp_hops`, `obp_source_rptr_id`, `ber`, `rssi` cuando aplica (ver [Tipos de evento](#tipos-de-evento) más arriba). Para probarlo de punta a punta: `enabled: true`, reload, haz una llamada o manda unit data a través del servidor, y revisa `example-events/.json` bajo el project root. Sus propios tests (`plugins/example/tests/`) cubren el tracking de sesión y la forma del JSON — ver [Tests](#tests) más abajo para correrlos. --- ## Tests Los tests del plugin van junto al plugin, no en `adn-server/tests/`: ```bash cd plugins/example python3 -m pytest tests/ -q ``` Los tests del framework (`test_plugin_bus.py`, `test_voice_bridge.py`, …) permanecen en el núcleo. --- ## Buenas prácticas 1. **No bloquear el reactor** en `on_event` — delegar I/O y trabajo pesado. 2. **Versionar `config.example.yaml`** junto al plugin; mantener `config.yaml` local y gitignored si lleva tokens. 3. **Capturar errores** en workers en segundo plano; excepciones en `on_event` activan el circuit breaker. 4. Usar **`depends_on`** cuando un plugin deba cargarse después de otro. 5. Copiar el skeleton **`example`** al crear un plugin nuevo — renombrar el directorio e implementar tu caso de uso.