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.
pull/1/head
Rodrigo Pérez 4 months ago
parent 34448e4fe0
commit af4264100e

@ -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).

@ -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).

@ -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.

@ -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).

@ -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.

@ -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

@ -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

@ -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]

@ -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"
}
]
}
]
}
}

@ -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"]
}

@ -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"
}
]
}
]
}

@ -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": []
}
]
}

@ -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
}

@ -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" }
]
}
}
}
}
}

@ -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/<path>/test_<name>.py -q # single file
python3 -m pytest tests/<path>/test_<name>.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 |

@ -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)
Loading…
Cancel
Save

Powered by TurnKey Linux.