From af4264100e6b0403a9bd97e83ffa6329f909c11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20P=C3=A9rez?= Date: Mon, 8 Jun 2026 09:44:48 -0400 Subject: [PATCH] docs(v2): add report v2 JSON schema and protocol docs (V2-P1-001) Define typed monitor payloads (hello, topology, routing_table, voice_event, delta) with JSON Schema, anonymized examples, pytest validation, and EN/ES docs. --- docs/en/server/development/testing.md | 2 +- docs/en/server/protocols/report-v2.md | 260 +++++++++++++++++++++++ docs/en/server/user-guide/monitoring.md | 2 + docs/es/server/protocols/report-v2.md | 257 ++++++++++++++++++++++ docs/es/server/user-guide/monitoring.md | 2 + mkdocs.es.yml | 1 + mkdocs.yml | 1 + pyproject.toml | 2 +- schemas/examples/delta.json | 25 +++ schemas/examples/hello.json | 17 ++ schemas/examples/routing_table.json | 39 ++++ schemas/examples/topology.json | 27 +++ schemas/examples/voice_event.json | 14 ++ schemas/report-v2.json | 221 +++++++++++++++++++ tests/README.md | 7 +- tests/schemas/test_report_v2_examples.py | 30 +++ 16 files changed, 903 insertions(+), 4 deletions(-) create mode 100644 docs/en/server/protocols/report-v2.md create mode 100644 docs/es/server/protocols/report-v2.md create mode 100644 schemas/examples/delta.json create mode 100644 schemas/examples/hello.json create mode 100644 schemas/examples/routing_table.json create mode 100644 schemas/examples/topology.json create mode 100644 schemas/examples/voice_event.json create mode 100644 schemas/report-v2.json create mode 100644 tests/schemas/test_report_v2_examples.py diff --git a/docs/en/server/development/testing.md b/docs/en/server/development/testing.md index de97f80..1674134 100644 --- a/docs/en/server/development/testing.md +++ b/docs/en/server/development/testing.md @@ -1,6 +1,6 @@ # Testing -Regression tests live under **`tests/`**, **one topic per file**, grouped by domain (`bridge/`, `hbp/`, `obp/`, …). See **[`tests/README.md`](../../../tests/README.md)** for the full file index. +Regression tests live under **`tests/`**, **one topic per file**, grouped by domain (`bridge/`, `hbp/`, `obp/`, …). Full file index: `tests/README.md` (repo root, maintainer checkout). They use an in-process **deterministic harness** (no Twisted reactor, no UDP sockets). diff --git a/docs/en/server/protocols/report-v2.md b/docs/en/server/protocols/report-v2.md new file mode 100644 index 0000000..95bb840 --- /dev/null +++ b/docs/en/server/protocols/report-v2.md @@ -0,0 +1,260 @@ +# Report protocol v2 (JSON) + +**Status:** schema draft (V2-P1-001). **Wire encoding** and server emission are **V2-P1-002**; monitor consumer is **V2-P1-005**. + +## Goals + +Replace monitor snapshots that today use **pickle** (`CONFIG_SND`, `BRIDGE_SND`) and **CSV strings** (`BRDG_EVENT`) with **typed JSON** that any client can decode. Legacy mode remains available via `REPORTS.PROTOCOL: legacy` (shim, V2-P1-003). + +## Transport (unchanged) + +- TCP **netstring** frames (Twisted `NetstringReceiver`), same port as v1 (`REPORTS.REPORT_PORT`). +- Each frame: **1-byte opcode** + **UTF-8 JSON payload** (v2) or pickle/CSV (v1). + +### Opcodes + +| Opcode | Hex | v1 payload | v2 payload | +|--------|-----|------------|------------| +| `HELLO` | `0xFF` | JSON hello (`protocol`: 1) | JSON hello (`report_protocol`: 2) | +| `CONFIG_SND` | `0x01` | pickle SYSTEMS | — (use `TOPOLOGY_SND`) | +| `BRIDGE_SND` | `0x03` | pickle BRIDGES | — (use `ROUTING_TABLE_SND`) | +| `BRDG_EVENT` | `0x07` | CSV text | — (use `VOICE_EVENT_SND`) | +| `TOPOLOGY_SND` | `0x10` | — | JSON `topology` | +| `ROUTING_TABLE_SND` | `0x11` | — | JSON `routing_table` | +| `VOICE_EVENT_SND` | `0x12` | — | JSON `voice_event` | +| `DELTA_SND` | `0x13` | — | JSON `delta` | + +Proposed opcodes `0x10`–`0x13` are reserved in the schema phase; exact values may change before P1-002 ships. + +## Handshake (`hello`) + +On connect the server sends **`HELLO` (`0xFF`)** first (same as today). v2 clients inspect `report_protocol`: + +```json +{ + "type": "hello", + "server": "adn-server", + "version": "2.0.0-alpha.1", + "report_protocol": 2, + "features": ["INGRESS", "END_TX_FORWARD", "PUSH_ON_CONNECT", "REPORT_V2", "TOPOLOGY_JSON", "ROUTING_TABLE_JSON", "VOICE_EVENT_JSON", "DELTA_UPDATES"], + "systems": ["MASTER-A", "OBP-CL"] +} +``` + +| Field | Notes | +|-------|--------| +| `report_protocol` | **2** for this schema. Distinct from legacy field `protocol: 1`. | +| `features` | v1 tokens unchanged; v2 adds `REPORT_V2` and payload capabilities. | + +Monitors that only understand v1 ignore unknown `features` and continue with pickle opcodes when `REPORTS.PROTOCOL=legacy`. + +## Message types + +| `type` | Replaces | Purpose | +|--------|----------|---------| +| `topology` | `CONFIG_SND` | Systems, peers, OpenBridge legs (no secrets). | +| `routing_table` | `BRIDGE_SND` | Active bridge legs per talkgroup / reflector key. | +| `voice_event` | `BRDG_EVENT` | Structured call start/end/ingress. | +| `delta` | — | Incremental `topology` or `routing_table` patch since `since_seq`. | + +## Reference payloads + +Each frame payload is one JSON object with a required `type`. Examples below use anonymized IDs. + +### `topology` + +```json +{ + "type": "topology", + "seq": 1, + "ts": 1717555200.0, + "systems": [ + { + "name": "MASTER-A", + "mode": "MASTER", + "enabled": true, + "ip": "10.0.0.1", + "port": 62030, + "repeat": true, + "peers": [ + { "id": 3120001, "connected": true, "ip": "10.0.0.50", "port": 62031 } + ] + }, + { + "name": "OBP-CL", + "mode": "OPENBRIDGE", + "enabled": true, + "ip": "10.0.0.2", + "port": 62044, + "enhanced_obp": true, + "peers": [] + } + ] +} +``` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `seq`, `ts` | int, float | yes | Monotonic sequence and epoch time. | +| `systems[].name` | string | yes | System key (matches config). | +| `systems[].mode` | string | yes | `MASTER`, `PEER`, or `OPENBRIDGE`. | +| `systems[].enabled` | bool | yes | Config enabled flag. | +| `systems[].ip`, `port` | string, int | no | Listen/connect endpoint. | +| `systems[].repeat` | bool | no | Master repeat flag. | +| `systems[].enhanced_obp` | bool | no | OpenBridge enhanced mode. | +| `systems[].peers[]` | array | no | `{id, connected, ip?, port?}` per peer radio. | + +No passwords or encryption material are included (unlike legacy pickle). + +### `routing_table` + +```json +{ + "type": "routing_table", + "seq": 42, + "ts": 1717555260.5, + "routes": [ + { + "bridge_key": "52090", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 52090, + "active": true, + "to_type": "ON", + "timer_expires_at": 1717555320.0 + }, + { + "system": "MASTER-B", + "ts": 2, + "tgid": 52090, + "active": true, + "to_type": "ON" + } + ] + }, + { + "bridge_key": "#310", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 310, + "active": false, + "to_type": "NONE" + } + ] + } + ] +} +``` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `routes[].bridge_key` | string | yes | Talkgroup id or reflector key (`#nnn`). | +| `legs[].system` | string | yes | Target system name. | +| `legs[].ts` | 1 \| 2 | yes | Timeslot (legacy `TS`). | +| `legs[].tgid` | int | yes | Talkgroup (1–16777215). | +| `legs[].active` | bool | yes | Leg active in BRIDGES table. | +| `legs[].to_type` | string | yes | `ON`, `OFF`, `STAT`, or `NONE`. | +| `legs[].timer_expires_at` | float | no | `rule_timer` expiry (legacy `TIMER`). | + +### `voice_event` + +Legacy CSV: + +```text +GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090 +``` + +v2 equivalent: + +```json +{ + "type": "voice_event", + "ts": 1717555201.234, + "call_family": "GROUP", + "phase": "START", + "direction": "RX", + "system": "MASTER-A", + "stream_id": 2155905152, + "peer_id": 1001, + "src_id": 3120001, + "slot": 2, + "dst_id": 52090, + "duration_s": null +} +``` + +| Field | Type | Required | Values / notes | +|-------|------|----------|----------------| +| `call_family` | string | yes | `GROUP`, `PRIVATE`, `UNIT`, `VCSSBK`. | +| `phase` | string | yes | `INGRESS`, `START`, `END`. | +| `direction` | string | yes | `RX`, `TX`. | +| `stream_id` | int | yes | 32-bit HBP stream id. | +| `peer_id`, `src_id`, `dst_id` | int | yes | DMR ids (1–16777215). | +| `slot` | int | yes | `1` or `2`. | +| `duration_s` | float \| null | no | Set on `END`. | + +OpenBridge **INGRESS** vs **START** semantics match [Monitoring and reports](../user-guide/monitoring.md#openbridge-monitor-semantics): use `phase: "INGRESS"` for first sight, `phase: "START"` after loop control. + +### `delta` + +```json +{ + "type": "delta", + "seq": 43, + "ts": 1717555261.0, + "since_seq": 42, + "patch": { + "type": "routing_table", + "seq": 43, + "ts": 1717555261.0, + "routes": [ + { + "bridge_key": "52090", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 52090, + "active": false, + "to_type": "ON" + } + ] + } + ] + } +} +``` + +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `since_seq` | int | yes | Last `seq` the client applied. | +| `patch` | object | yes | Partial `topology` or `routing_table` (same shape). | + +## Sequencing + +- `topology` and `routing_table` messages carry monotonic `seq` (uint) and `ts` (float epoch). +- Clients track last applied `seq`; `delta` messages set `since_seq` to the client watermark. +- Full snapshots may still be sent on connect and on `REPORTS.REPORT_INTERVAL` (same triggers as v1 CONFIG/BRIDGE). + +## Configuration (planned) + +```yaml +REPORTS: + REPORT: true + REPORT_PORT: 4321 + PROTOCOL: legacy # legacy | v2 (default legacy until 2.0.0) +``` + +## Compatibility + +| Server | Monitor | Mode | +|--------|---------|------| +| 1.0.x | 1.0.x | legacy + HELLO v1 | +| 2.0.0-alpha | 1.0.x | legacy shim | +| 2.0.0-alpha | 2.x | report v2 opt-in | + +See also [Monitoring and reports](../user-guide/monitoring.md). diff --git a/docs/en/server/user-guide/monitoring.md b/docs/en/server/user-guide/monitoring.md index 2534c91..4b6b389 100644 --- a/docs/en/server/user-guide/monitoring.md +++ b/docs/en/server/user-guide/monitoring.md @@ -8,6 +8,8 @@ When **`REPORTS`** is enabled in the server config, the **ADN DMR Peer Server** - **CONFIG_SND** / **BRIDGE_SND** — pickled snapshots of systems and bridges (sent immediately after HELLO on connect, on **`CONFIG_REQ`** / **`BRIDGE_REQ`**, on **SIGHUP** config reload, when a **MASTER** hotspot **registers or disconnects**, and on the periodic **`REPORT_INTERVAL`** loop). - **BRDG_EVENT** — text events for calls (`GROUP VOICE`, `PRIVATE VOICE`, etc.). +**Report v2 (draft):** typed JSON messages (`topology`, `routing_table`, `voice_event`, `delta`) will replace pickle/CSV when `REPORTS.PROTOCOL: v2` (Phase 1). Schema and wire layout: [Report protocol v2 (JSON)](../protocols/report-v2.md). + Older stacks (**legacy** `adn-dmr-server`-style) may **omit** HELLO. **adn-monitor** waits up to **`ADN_CONNECTION.HELLO_TIMEOUT_MS`** (see [Monitor configuration](../../monitor/configuration.md#adn_connection)); if no HELLO arrives, it assumes **legacy** reporting. The **monitor** decodes these messages, updates its **CTABLE** / **BTABLE**, and (when MySQL is configured) persists Last Heard / statistics. diff --git a/docs/es/server/protocols/report-v2.md b/docs/es/server/protocols/report-v2.md new file mode 100644 index 0000000..6dfb055 --- /dev/null +++ b/docs/es/server/protocols/report-v2.md @@ -0,0 +1,257 @@ +# Protocolo de informes v2 (JSON) + +**Estado:** borrador de esquema (V2-P1-001). La **codificación en wire** y la emisión en el servidor son **V2-P1-002**; el consumidor en monitor es **V2-P1-005**. + +## Objetivos + +Sustituir instantáneas al monitor que hoy usan **pickle** (`CONFIG_SND`, `BRIDGE_SND`) y **CSV** (`BRDG_EVENT`) por **JSON tipado**. El modo legacy sigue con `REPORTS.PROTOCOL: legacy` (shim, V2-P1-003). + +## Transporte (sin cambios) + +- TCP **netstring** (Twisted `NetstringReceiver`), mismo puerto que v1 (`REPORTS.REPORT_PORT`). +- Cada trama: **1 byte de opcode** + payload **JSON UTF-8** (v2) o pickle/CSV (v1). + +### Opcodes + +| Opcode | Hex | Payload v1 | Payload v2 | +|--------|-----|------------|------------| +| `HELLO` | `0xFF` | JSON hello (`protocol`: 1) | JSON hello (`report_protocol`: 2) | +| `CONFIG_SND` | `0x01` | pickle SYSTEMS | — (`TOPOLOGY_SND`) | +| `BRIDGE_SND` | `0x03` | pickle BRIDGES | — (`ROUTING_TABLE_SND`) | +| `BRDG_EVENT` | `0x07` | texto CSV | — (`VOICE_EVENT_SND`) | +| `TOPOLOGY_SND` | `0x10` | — | JSON `topology` | +| `ROUTING_TABLE_SND` | `0x11` | — | JSON `routing_table` | +| `VOICE_EVENT_SND` | `0x12` | — | JSON `voice_event` | +| `DELTA_SND` | `0x13` | — | JSON `delta` | + +Los opcodes `0x10`–`0x13` están reservados en esta fase; pueden ajustarse antes de P1-002. + +## Handshake (`hello`) + +Al conectar, el servidor envía **`HELLO` (`0xFF`)** primero. Clientes v2 miran `report_protocol`: + +```json +{ + "type": "hello", + "server": "adn-server", + "version": "2.0.0-alpha.1", + "report_protocol": 2, + "features": ["INGRESS", "END_TX_FORWARD", "PUSH_ON_CONNECT", "REPORT_V2", "TOPOLOGY_JSON", "ROUTING_TABLE_JSON", "VOICE_EVENT_JSON", "DELTA_UPDATES"], + "systems": ["MASTER-A", "OBP-CL"] +} +``` + +| Campo | Notas | +|-------|--------| +| `report_protocol` | **2** para este esquema. Distinto del campo legacy `protocol: 1`. | +| `features` | Tokens v1 sin cambios; v2 añade `REPORT_V2` y capacidades de payload. | + +## Tipos de mensaje + +| `type` | Sustituye | Uso | +|--------|-----------|-----| +| `topology` | `CONFIG_SND` | Sistemas, peers, piernas OBP (sin secretos). | +| `routing_table` | `BRIDGE_SND` | Piernas de bridge por TG / reflector. | +| `voice_event` | `BRDG_EVENT` | Inicio/fin/ingress de llamadas. | +| `delta` | — | Parche incremental desde `since_seq`. | + +## Payloads de referencia + +Cada trama lleva un único objeto JSON con `type` obligatorio. Los ejemplos usan IDs anonimizados. + +### `topology` + +```json +{ + "type": "topology", + "seq": 1, + "ts": 1717555200.0, + "systems": [ + { + "name": "MASTER-A", + "mode": "MASTER", + "enabled": true, + "ip": "10.0.0.1", + "port": 62030, + "repeat": true, + "peers": [ + { "id": 3120001, "connected": true, "ip": "10.0.0.50", "port": 62031 } + ] + }, + { + "name": "OBP-CL", + "mode": "OPENBRIDGE", + "enabled": true, + "ip": "10.0.0.2", + "port": 62044, + "enhanced_obp": true, + "peers": [] + } + ] +} +``` + +| Campo | Tipo | Obligatorio | Notas | +|-------|------|-------------|-------| +| `seq`, `ts` | int, float | sí | Secuencia monótona y epoch. | +| `systems[].name` | string | sí | Clave de sistema (config). | +| `systems[].mode` | string | sí | `MASTER`, `PEER` u `OPENBRIDGE`. | +| `systems[].enabled` | bool | sí | Flag enabled de config. | +| `systems[].ip`, `port` | string, int | no | Endpoint de escucha/conexión. | +| `systems[].repeat` | bool | no | Repeat en master. | +| `systems[].enhanced_obp` | bool | no | OBP enhanced. | +| `systems[].peers[]` | array | no | `{id, connected, ip?, port?}` por radio peer. | + +Sin contraseñas ni material de cifrado (a diferencia del pickle legacy). + +### `routing_table` + +```json +{ + "type": "routing_table", + "seq": 42, + "ts": 1717555260.5, + "routes": [ + { + "bridge_key": "52090", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 52090, + "active": true, + "to_type": "ON", + "timer_expires_at": 1717555320.0 + }, + { + "system": "MASTER-B", + "ts": 2, + "tgid": 52090, + "active": true, + "to_type": "ON" + } + ] + }, + { + "bridge_key": "#310", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 310, + "active": false, + "to_type": "NONE" + } + ] + } + ] +} +``` + +| Campo | Tipo | Obligatorio | Notas | +|-------|------|-------------|-------| +| `routes[].bridge_key` | string | sí | TG o clave reflector (`#nnn`). | +| `legs[].system` | string | sí | Nombre del sistema destino. | +| `legs[].ts` | 1 \| 2 | sí | Timeslot (legacy `TS`). | +| `legs[].tgid` | int | sí | Talkgroup (1–16777215). | +| `legs[].active` | bool | sí | Pierna activa en BRIDGES. | +| `legs[].to_type` | string | sí | `ON`, `OFF`, `STAT` o `NONE`. | +| `legs[].timer_expires_at` | float | no | Expiración `rule_timer` (legacy `TIMER`). | + +### `voice_event` + +CSV legacy: + +```text +GROUP VOICE,START,RX,MASTER-A,2155905152,1001,3120001,2,52090 +``` + +Equivalente v2: + +```json +{ + "type": "voice_event", + "ts": 1717555201.234, + "call_family": "GROUP", + "phase": "START", + "direction": "RX", + "system": "MASTER-A", + "stream_id": 2155905152, + "peer_id": 1001, + "src_id": 3120001, + "slot": 2, + "dst_id": 52090, + "duration_s": null +} +``` + +| Campo | Tipo | Obligatorio | Valores / notas | +|-------|------|-------------|-----------------| +| `call_family` | string | sí | `GROUP`, `PRIVATE`, `UNIT`, `VCSSBK`. | +| `phase` | string | sí | `INGRESS`, `START`, `END`. | +| `direction` | string | sí | `RX`, `TX`. | +| `stream_id` | int | sí | Stream HBP 32 bits. | +| `peer_id`, `src_id`, `dst_id` | int | sí | IDs DMR (1–16777215). | +| `slot` | int | sí | `1` o `2`. | +| `duration_s` | float \| null | no | En eventos `END`. | + +Semántica OpenBridge INGRESS/START: ver [Monitor e informes](../user-guide/monitoring.md). + +### `delta` + +```json +{ + "type": "delta", + "seq": 43, + "ts": 1717555261.0, + "since_seq": 42, + "patch": { + "type": "routing_table", + "seq": 43, + "ts": 1717555261.0, + "routes": [ + { + "bridge_key": "52090", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 52090, + "active": false, + "to_type": "ON" + } + ] + } + ] + } +} +``` + +| Campo | Tipo | Obligatorio | Notas | +|-------|------|-------------|-------| +| `since_seq` | int | sí | Último `seq` aplicado por el cliente. | +| `patch` | object | sí | Parcial `topology` o `routing_table` (misma forma). | + +## Secuenciación + +- `topology` y `routing_table` llevan `seq` (uint) y `ts` (epoch float). +- `delta` indica `since_seq` respecto al último `seq` aplicado por el cliente. + +## Configuración (prevista) + +```yaml +REPORTS: + REPORT: true + REPORT_PORT: 4321 + PROTOCOL: legacy # legacy | v2 +``` + +## Compatibilidad + +| Servidor | Monitor | Modo | +|----------|---------|------| +| 1.0.x | 1.0.x | legacy + HELLO v1 | +| 2.0.0-alpha | 1.0.x | shim legacy | +| 2.0.0-alpha | 2.x | report v2 opt-in | + +Ver también [Monitor e informes](../user-guide/monitoring.md). diff --git a/docs/es/server/user-guide/monitoring.md b/docs/es/server/user-guide/monitoring.md index eb0ac90..aa89f68 100644 --- a/docs/es/server/user-guide/monitoring.md +++ b/docs/es/server/user-guide/monitoring.md @@ -8,6 +8,8 @@ Cuando **`REPORTS`** está habilitado en la config del servidor, el **ADN DMR Pe - **CONFIG_SND** / **BRIDGE_SND** — instantáneas pickle de sistemas y bridges (tras HELLO al conectar, en **`CONFIG_REQ`** / **`BRIDGE_REQ`**, en **reload** de config (**SIGHUP**), cuando un hotspot **MASTER** **registra o desconecta**, y en el bucle periódico **`REPORT_INTERVAL`**). - **BRDG_EVENT** — eventos de texto para llamadas (`GROUP VOICE`, `PRIVATE VOICE`, etc.). +**Informes v2 (borrador):** mensajes JSON tipados (`topology`, `routing_table`, `voice_event`, `delta`) sustituirán pickle/CSV con `REPORTS.PROTOCOL: v2` (Fase 1). Esquema y wire: [Protocolo de informes v2 (JSON)](../protocols/report-v2.md). + Las pilas antiguas (**legado** estilo `adn-dmr-server`) pueden **omitir** HELLO. **adn-monitor** espera hasta **`ADN_CONNECTION.HELLO_TIMEOUT_MS`** (ver [Configuración del monitor](../../monitor/configuration.md#adn_connection)); si no llega HELLO, asume informes **legacy**. El **monitor** decodifica estos mensajes, actualiza **CTABLE** / **BTABLE** y (con MySQL configurado) persiste Last Heard / estadísticas. diff --git a/mkdocs.es.yml b/mkdocs.es.yml index e97d9df..964b21a 100644 --- a/mkdocs.es.yml +++ b/mkdocs.es.yml @@ -62,6 +62,7 @@ nav: - HBP (DMRD): server/protocols/hbp.md - OpenBridge (DMRE): server/protocols/openbridge.md - Trama DMRE v5: server/protocols/dmre-v5.md + - Protocolo de informes v2 (JSON): server/protocols/report-v2.md - Desarrollo: - Arquitectura: server/development/architecture.md - Comportamiento y temporizadores: server/development/behaviour-and-timers.md diff --git a/mkdocs.yml b/mkdocs.yml index 9e78855..bc59f52 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - HBP (DMRD): server/protocols/hbp.md - OpenBridge (DMRE): server/protocols/openbridge.md - DMRE v5 frame layout: server/protocols/dmre-v5.md + - Report protocol v2 (JSON): server/protocols/report-v2.md - Development: - Architecture: server/development/architecture.md - Behaviour and timers: server/development/behaviour-and-timers.md diff --git a/pyproject.toml b/pyproject.toml index 4ae6fdf..135c891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=7.0"] +dev = ["pytest>=7.0", "jsonschema>=4.0"] docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "pymdown-extensions>=10.3"] [tool.setuptools.packages.find] diff --git a/schemas/examples/delta.json b/schemas/examples/delta.json new file mode 100644 index 0000000..b8e35b3 --- /dev/null +++ b/schemas/examples/delta.json @@ -0,0 +1,25 @@ +{ + "type": "delta", + "seq": 43, + "ts": 1717555261.0, + "since_seq": 42, + "patch": { + "type": "routing_table", + "seq": 43, + "ts": 1717555261.0, + "routes": [ + { + "bridge_key": "52090", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 52090, + "active": false, + "to_type": "ON" + } + ] + } + ] + } +} diff --git a/schemas/examples/hello.json b/schemas/examples/hello.json new file mode 100644 index 0000000..ea685f2 --- /dev/null +++ b/schemas/examples/hello.json @@ -0,0 +1,17 @@ +{ + "type": "hello", + "server": "adn-server", + "version": "2.0.0-alpha.1", + "report_protocol": 2, + "features": [ + "INGRESS", + "END_TX_FORWARD", + "PUSH_ON_CONNECT", + "REPORT_V2", + "TOPOLOGY_JSON", + "ROUTING_TABLE_JSON", + "VOICE_EVENT_JSON", + "DELTA_UPDATES" + ], + "systems": ["MASTER-A", "MASTER-B", "OBP-CL"] +} diff --git a/schemas/examples/routing_table.json b/schemas/examples/routing_table.json new file mode 100644 index 0000000..eae1e55 --- /dev/null +++ b/schemas/examples/routing_table.json @@ -0,0 +1,39 @@ +{ + "type": "routing_table", + "seq": 42, + "ts": 1717555260.5, + "routes": [ + { + "bridge_key": "52090", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 52090, + "active": true, + "to_type": "ON", + "timer_expires_at": 1717555320.0 + }, + { + "system": "MASTER-B", + "ts": 2, + "tgid": 52090, + "active": true, + "to_type": "ON" + } + ] + }, + { + "bridge_key": "#310", + "legs": [ + { + "system": "MASTER-A", + "ts": 2, + "tgid": 310, + "active": false, + "to_type": "NONE" + } + ] + } + ] +} diff --git a/schemas/examples/topology.json b/schemas/examples/topology.json new file mode 100644 index 0000000..7df26b8 --- /dev/null +++ b/schemas/examples/topology.json @@ -0,0 +1,27 @@ +{ + "type": "topology", + "seq": 1, + "ts": 1717555200.0, + "systems": [ + { + "name": "MASTER-A", + "mode": "MASTER", + "enabled": true, + "ip": "10.0.0.1", + "port": 62030, + "repeat": true, + "peers": [ + { "id": 3120001, "connected": true, "ip": "10.0.0.50", "port": 62031 } + ] + }, + { + "name": "OBP-CL", + "mode": "OPENBRIDGE", + "enabled": true, + "ip": "10.0.0.2", + "port": 62044, + "enhanced_obp": true, + "peers": [] + } + ] +} diff --git a/schemas/examples/voice_event.json b/schemas/examples/voice_event.json new file mode 100644 index 0000000..75931fe --- /dev/null +++ b/schemas/examples/voice_event.json @@ -0,0 +1,14 @@ +{ + "type": "voice_event", + "ts": 1717555201.234, + "call_family": "GROUP", + "phase": "START", + "direction": "RX", + "system": "MASTER-A", + "stream_id": 2155905152, + "peer_id": 1001, + "src_id": 3120001, + "slot": 2, + "dst_id": 52090, + "duration_s": null +} diff --git a/schemas/report-v2.json b/schemas/report-v2.json new file mode 100644 index 0000000..597aa75 --- /dev/null +++ b/schemas/report-v2.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://adn.systems/schemas/report-v2.json", + "title": "ADN Monitor Report Protocol v2", + "description": "JSON payloads for the TCP report channel (netstring-framed). Legacy v1 uses pickle CONFIG_SND/BRIDGE_SND and CSV BRDG_EVENT; v2 replaces those with typed JSON. Wire opcodes are defined in docs/en/server/protocols/report-v2.md; implementation is V2-P1-002.", + "type": "object", + "required": ["type"], + "oneOf": [ + { "$ref": "#/$defs/hello" }, + { "$ref": "#/$defs/topology" }, + { "$ref": "#/$defs/routing_table" }, + { "$ref": "#/$defs/voice_event" }, + { "$ref": "#/$defs/delta" } + ], + "$defs": { + "dmr_id": { + "type": "integer", + "minimum": 1, + "maximum": 16777215, + "description": "DMR radio ID or talkgroup (24-bit)." + }, + "stream_id": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "HBP stream identifier (32-bit, unsigned)." + }, + "slot": { + "type": "integer", + "enum": [1, 2] + }, + "system_mode": { + "type": "string", + "enum": ["MASTER", "PEER", "OPENBRIDGE"] + }, + "to_type": { + "type": "string", + "enum": ["ON", "OFF", "STAT", "NONE"], + "description": "Legacy BRIDGES TO_TYPE semantics until subscription model (Phase 2)." + }, + "hello": { + "type": "object", + "additionalProperties": false, + "required": ["type", "server", "version", "report_protocol", "features"], + "properties": { + "type": { "const": "hello" }, + "server": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1, "description": "adn-server package version." }, + "report_protocol": { + "type": "integer", + "const": 2, + "description": "Report payload schema version (distinct from legacy HELLO protocol field value 1)." + }, + "features": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "description": "Capability tokens. v1: INGRESS, END_TX_FORWARD, PUSH_ON_CONNECT. v2 adds REPORT_V2, TOPOLOGY_JSON, ROUTING_TABLE_JSON, VOICE_EVENT_JSON, DELTA_UPDATES." + }, + "systems": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional system names known at handshake (full topology follows in topology message)." + } + } + }, + "topology_system": { + "type": "object", + "additionalProperties": false, + "required": ["name", "mode", "enabled"], + "properties": { + "name": { "type": "string" }, + "mode": { "$ref": "#/$defs/system_mode" }, + "enabled": { "type": "boolean" }, + "ip": { "type": "string" }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "repeat": { "type": "boolean" }, + "enhanced_obp": { "type": "boolean" }, + "peers": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "connected"], + "properties": { + "id": { "$ref": "#/$defs/dmr_id" }, + "connected": { "type": "boolean" }, + "ip": { "type": "string" }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 } + } + } + } + } + }, + "topology": { + "type": "object", + "additionalProperties": false, + "required": ["type", "seq", "ts", "systems"], + "properties": { + "type": { "const": "topology" }, + "seq": { "type": "integer", "minimum": 0 }, + "ts": { "type": "number", "description": "Unix epoch seconds (fractional allowed)." }, + "systems": { + "type": "array", + "items": { "$ref": "#/$defs/topology_system" } + } + } + }, + "routing_leg": { + "type": "object", + "additionalProperties": false, + "required": ["system", "ts", "tgid", "active", "to_type"], + "properties": { + "system": { "type": "string" }, + "ts": { "$ref": "#/$defs/slot" }, + "tgid": { "$ref": "#/$defs/dmr_id" }, + "active": { "type": "boolean" }, + "to_type": { "$ref": "#/$defs/to_type" }, + "timer_expires_at": { + "type": "number", + "description": "Unix time when rule_timer deactivates leg (legacy TIMER field)." + } + } + }, + "routing_table": { + "type": "object", + "additionalProperties": false, + "required": ["type", "seq", "ts", "routes"], + "properties": { + "type": { "const": "routing_table" }, + "seq": { "type": "integer", "minimum": 0 }, + "ts": { "type": "number" }, + "routes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["bridge_key", "legs"], + "properties": { + "bridge_key": { + "type": "string", + "description": "Talkgroup id string or reflector key (#xxx)." + }, + "legs": { + "type": "array", + "items": { "$ref": "#/$defs/routing_leg" } + } + } + } + } + } + }, + "voice_event": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "ts", + "call_family", + "phase", + "direction", + "system", + "stream_id", + "peer_id", + "src_id", + "slot", + "dst_id" + ], + "properties": { + "type": { "const": "voice_event" }, + "ts": { "type": "number" }, + "call_family": { + "type": "string", + "enum": ["GROUP", "PRIVATE", "UNIT", "VCSSBK"], + "description": "Replaces BRDG_EVENT prefix (e.g. GROUP VOICE)." + }, + "phase": { + "type": "string", + "enum": ["INGRESS", "START", "END"] + }, + "direction": { + "type": "string", + "enum": ["RX", "TX"] + }, + "system": { "type": "string" }, + "stream_id": { "$ref": "#/$defs/stream_id" }, + "peer_id": { "$ref": "#/$defs/dmr_id" }, + "src_id": { "$ref": "#/$defs/dmr_id" }, + "slot": { "$ref": "#/$defs/slot" }, + "dst_id": { "$ref": "#/$defs/dmr_id" }, + "duration_s": { + "type": ["number", "null"], + "minimum": 0, + "description": "Present on END events." + } + } + }, + "delta": { + "type": "object", + "additionalProperties": false, + "required": ["type", "seq", "ts", "since_seq", "patch"], + "properties": { + "type": { "const": "delta" }, + "seq": { "type": "integer", "minimum": 1 }, + "ts": { "type": "number" }, + "since_seq": { + "type": "integer", + "minimum": 0, + "description": "Client last applied seq; server sends changes after this." + }, + "patch": { + "description": "Partial topology or routing_table payload (same shape, may omit unchanged branches).", + "oneOf": [ + { "$ref": "#/$defs/topology" }, + { "$ref": "#/$defs/routing_table" } + ] + } + } + } + } +} diff --git a/tests/README.md b/tests/README.md index bca90d4..eecdd64 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,11 +3,12 @@ One **topic per file** — run only what you need while developing or validating a change. ```bash -python3 -m pip install -e ".[dev]" +# Install into the pyenv site-packages (not ~/.local); Cursor/VS Code pytest sets PYTHONNOUSERSITE=1 +python3 -m pip install --no-user -e ".[dev]" python3 -m pytest tests//test_.py -q # single file python3 -m pytest tests//test_.py::test_foo -q # single test python3 -m pytest tests/bridge/ -q # whole domain -python3 -m pytest tests/ -q # full suite (152) +python3 -m pytest tests/ -q # full suite (169 + 1 skip) ``` Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`. @@ -22,6 +23,8 @@ Use the project interpreter, e.g. `/opt/.pyenv/versions/3.11.8/bin/python3`. | `voice/` | Announcements, TTS schedule, broadcast queue, disconnected voice, in-band signalling | | `talker_alias/` | Encode/decode, passthrough, MMDVM wire, bridge inject | | `parrot/` | Recording timers, playback loop, seq preservation, ingress path | +| `replay/` | JSONL session replay (V2-P0-007) | +| `schemas/` | Report v2 JSON Schema validation (`jsonschema` dev dep) | | `smoke/` | Quick routing smoke + packet builder | | `infrastructure/` | Logging reload, bridge router index | | `application/` | RuntimeContext holder / config proxy | diff --git a/tests/schemas/test_report_v2_examples.py b/tests/schemas/test_report_v2_examples.py new file mode 100644 index 0000000..cc1f20f --- /dev/null +++ b/tests/schemas/test_report_v2_examples.py @@ -0,0 +1,30 @@ +"""Validate report v2 JSON Schema against committed examples (V2-P1-001).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import pytest + +_SCHEMA_PATH = Path(__file__).resolve().parents[2] / "schemas" / "report-v2.json" +_EXAMPLES_DIR = _SCHEMA_PATH.parent / "examples" + + +@pytest.fixture(scope="module") +def report_v2_schema() -> dict: + with _SCHEMA_PATH.open(encoding="utf-8") as fh: + return json.load(fh) + + +@pytest.fixture(scope="module") +def validator(report_v2_schema: dict) -> jsonschema.Draft202012Validator: + return jsonschema.Draft202012Validator(report_v2_schema) + + +@pytest.mark.parametrize("path", sorted(_EXAMPLES_DIR.glob("*.json")), ids=lambda p: p.stem) +def test_example_validates(path: Path, validator: jsonschema.Draft202012Validator) -> None: + with path.open(encoding="utf-8") as fh: + doc = json.load(fh) + validator.validate(doc)