You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
270 lines
9.6 KiB
270 lines
9.6 KiB
# 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/<name>/` cargado en tiempo de ejecución.
|
|
|
|
El repositorio incluye un skeleton de referencia: `plugins/example/` (deshabilitado por defecto).
|
|
|
|
---
|
|
|
|
## Estructura de directorios
|
|
|
|
```
|
|
plugins/<plugin-name>/
|
|
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: <project_root>/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/<name>/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/<name>/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: `<output_dir>/<stream_id>.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/<stream_id>.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.
|