Compare commits
No files matched your search
@@ -98,3 +98,7 @@ local_subscriber_ids.*
|
||||
peer_ids.*
|
||||
local_peer_ids.*
|
||||
talkgroup_ids.*
|
||||
|
||||
# Dashboard local config (copy from dashboard/config_sample.py)
|
||||
dashboard/config.py
|
||||
dashboard/__pycache__/
|
||||
@@ -0,0 +1,271 @@
|
||||
# HBlink3 Access Control Lists (ACLs)
|
||||
|
||||
Access Control Lists are one of the most powerful — and most under-documented —
|
||||
features in HBlink3. They let you decide, packet by packet, **who** may use your
|
||||
system, **which subscribers** may talk through it, and **which talkgroups** are
|
||||
allowed on each timeslot. This document explains the ACL grammar, the exact
|
||||
matching rules (verified against the code in `config.py` and `hblink.py`), a set
|
||||
of worked examples from simple to advanced, and honest guidance on performance.
|
||||
|
||||
ACLs are cheap and safe to use. Read the [Performance](#performance) section and
|
||||
you will see there is almost no reason *not* to use them.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two-part grammar
|
||||
|
||||
Every ACL is a single string of the form:
|
||||
|
||||
```
|
||||
ACTION:entry,entry,entry,...
|
||||
```
|
||||
|
||||
* **ACTION** is either `PERMIT` or `DENY`.
|
||||
* **entries** are comma-separated, and each entry is one of:
|
||||
* a **single ID** — e.g. `3120101`
|
||||
* a **range** — two IDs joined by a hyphen, low first — e.g. `1000-2000`
|
||||
* the literal keyword **`ALL`** — matches every possible ID.
|
||||
|
||||
Examples of valid ACLs:
|
||||
|
||||
```
|
||||
PERMIT:ALL
|
||||
DENY:1
|
||||
DENY:1,1000-2000,4500-60000,17
|
||||
PERMIT:3120000-3120999,3123456
|
||||
```
|
||||
|
||||
An **empty** value is treated as `PERMIT:ALL` (permit everything).
|
||||
|
||||
> There is exactly **one** ACTION per ACL. You cannot mix `PERMIT` and `DENY`
|
||||
> entries in the same list. If you need "allow these but block those," you layer
|
||||
> two ACLs — see [Section 6](#6-layering-global-and-system-acls).
|
||||
|
||||
---
|
||||
|
||||
## 2. The single most important rule: what happens to IDs you *didn't* list
|
||||
|
||||
This is the part that trips everyone up, so read it twice.
|
||||
|
||||
An ACL does two things at once. It defines an ACTION for the IDs you **list**,
|
||||
and it *implies the opposite ACTION* for every ID you **don't** list.
|
||||
|
||||
| You write | A **listed** ID is… | An **unlisted** ID is… | Mental model |
|
||||
|-----------|--------------------|------------------------|--------------|
|
||||
| `PERMIT:…` | permitted | **denied** | **whitelist** — only these get in |
|
||||
| `DENY:…` | denied | **permitted** | **blacklist** — everyone except these |
|
||||
|
||||
So:
|
||||
|
||||
* `PERMIT:3120100-3120199` means *"allow only IDs 3120100–3120199, block the
|
||||
rest of the world."*
|
||||
* `DENY:3120100-3120199` means *"block IDs 3120100–3120199, allow everyone
|
||||
else."*
|
||||
|
||||
A listed ID gets the ACTION; an unlisted ID gets the opposite.
|
||||
|
||||
**Rule of thumb:** use `DENY:` when you want to block a small, known set and let
|
||||
everyone else through. Use `PERMIT:` when you want a closed system where only an
|
||||
explicit list is allowed.
|
||||
|
||||
`PERMIT:ALL` and `DENY:1` (deny the reserved ID 1, permit all real IDs) are the
|
||||
two most common "effectively open" settings.
|
||||
|
||||
---
|
||||
|
||||
## 3. The four ACL types
|
||||
|
||||
HBlink3 applies four kinds of ACL. Each is a separate config key.
|
||||
|
||||
| Key | Gates | Value domain | Notes |
|
||||
|-----|-------|--------------|-------|
|
||||
| `REG_ACL` | **Repeater/peer registration** (login) | peer IDs, `1 … 4294967295` | Only meaningful on **SERVER** systems. Outbound/OpenBridge links don't accept registrations. **Always enforced** — see §5. |
|
||||
| `SUB_ACL` | **Subscriber** (the source radio ID of a call) | subscriber IDs, `1 … 16776415` | Applied to every voice/data stream. |
|
||||
| `TGID_TS1_ACL` | **Destination talkgroup on Timeslot 1** | talkgroup IDs, `1 … 16776415` | |
|
||||
| `TGID_TS2_ACL` | **Destination talkgroup on Timeslot 2** | talkgroup IDs, `1 … 16776415` | |
|
||||
|
||||
The value domains matter: subscriber and talkgroup IDs are checked against a
|
||||
maximum of **16776415** (`const.ID_MAX`), while registration (peer) IDs go up to
|
||||
**4294967295** (`const.PEER_MAX`). An entry outside the valid range causes
|
||||
HBlink3 to **exit at startup** with an `ACL CREATION ERROR` — this is a
|
||||
guardrail, not a bug (see [Warnings](#warnings)).
|
||||
|
||||
---
|
||||
|
||||
## 4. Where each ACL is enforced
|
||||
|
||||
For an inbound **DMRD voice/data** frame, HBlink3 checks (in order):
|
||||
|
||||
1. GLOBAL `SUB_ACL` — source subscriber
|
||||
2. GLOBAL `TGID_TS1_ACL` or `TGID_TS2_ACL` — destination TG, by slot
|
||||
3. SYSTEM `SUB_ACL` — source subscriber
|
||||
4. SYSTEM `TGID_TS1_ACL` or `TGID_TS2_ACL` — destination TG, by slot
|
||||
|
||||
The call is dropped at the **first** ACL that denies it. It must pass **all
|
||||
four** to be forwarded.
|
||||
|
||||
For **registration** (a repeater logging into a master), both the GLOBAL and the
|
||||
SYSTEM `REG_ACL` must permit the peer — if either one denies it, the login is
|
||||
refused.
|
||||
|
||||
### OpenBridge special case
|
||||
|
||||
OpenBridge systems do not register (no `REG_ACL`), and **all OpenBridge traffic
|
||||
is carried on Timeslot 1**. That means the **global `TGID_TS1_ACL`** is your
|
||||
talkgroup filter for OpenBridge peers. TS2 is forced to `PERMIT:ALL` internally
|
||||
for OpenBridge systems.
|
||||
|
||||
---
|
||||
|
||||
## 5. The `USE_ACL` switch — and the one ACL it can't turn off
|
||||
|
||||
Each stanza has a `USE_ACL` boolean.
|
||||
|
||||
* `USE_ACL: True` — enforce this stanza's `SUB_ACL` and TGID ACLs.
|
||||
* `USE_ACL: False` — skip the subscriber and talkgroup checks for this stanza
|
||||
(traffic passes freely).
|
||||
|
||||
**Registration ACLs are always enforced.** `REG_ACL` is *not* governed by
|
||||
`USE_ACL`. Even with `USE_ACL: False`, a master will still refuse a repeater that
|
||||
its `REG_ACL` denies. This is by design: registration is your front door.
|
||||
|
||||
> Note: the stanzas still require the ACL keys to be present even when
|
||||
> `USE_ACL: False`. Leave them at `PERMIT:ALL` if unused.
|
||||
|
||||
---
|
||||
|
||||
## 6. Layering GLOBAL and SYSTEM ACLs
|
||||
|
||||
Because GLOBAL is checked first and SYSTEM second, and **both must pass**, you
|
||||
can compose behaviors that a single `PERMIT`/`DENY` line cannot express. Think of
|
||||
GLOBAL as your site-wide policy and SYSTEM as per-connection refinement.
|
||||
|
||||
**Example — a global blacklist plus a per-system whitelist.**
|
||||
|
||||
```ini
|
||||
[GLOBAL]
|
||||
USE_ACL: True
|
||||
SUB_ACL: DENY:1,2,3 # site-wide: block a few abusive IDs, allow the rest
|
||||
|
||||
[LOCAL-CLUB-REPEATER]
|
||||
USE_ACL: True
|
||||
SUB_ACL: PERMIT:3120000-3120999 # this repeater: ONLY our club's ID block
|
||||
```
|
||||
|
||||
A subscriber must (a) not be one of the globally blocked IDs **and** (b) fall
|
||||
inside the club's block. The intersection is exactly what you want, and neither
|
||||
ACL alone could express it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Worked examples
|
||||
|
||||
All examples assume `USE_ACL: True` in the relevant stanza.
|
||||
|
||||
**Open system, tidy.** Allow every real subscriber, block only the reserved
|
||||
ID 1:
|
||||
|
||||
```ini
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
```
|
||||
|
||||
**Closed talkgroup policy on TS1.** Permit only a curated set of statewide and
|
||||
local talkgroups on Timeslot 1; leave TS2 open:
|
||||
|
||||
```ini
|
||||
TGID_TS1_ACL: PERMIT:8,9,3100-3199,31201,31203
|
||||
TGID_TS2_ACL: DENY:1
|
||||
```
|
||||
|
||||
`3100-3199` covers the whole regional block in one range; `8`, `9`, `31201`,
|
||||
`31203` are individual talkgroups. Everything else on TS1 is dropped.
|
||||
|
||||
**Ban a handful of subscribers network-wide** while permitting everyone else:
|
||||
|
||||
```ini
|
||||
[GLOBAL]
|
||||
SUB_ACL: DENY:1,2606234,3141592,3120555
|
||||
```
|
||||
|
||||
**Members-only repeater.** Only your membership ID range may key up, and only on
|
||||
approved talkgroups:
|
||||
|
||||
```ini
|
||||
[MEMBERS-ONLY]
|
||||
USE_ACL: True
|
||||
SUB_ACL: PERMIT:3120000-3120499
|
||||
TGID_TS1_ACL: PERMIT:2,9,3120
|
||||
TGID_TS2_ACL: PERMIT:3120,31205
|
||||
```
|
||||
|
||||
**Registration control on a server.** Accept only two specific repeater IDs onto
|
||||
this server; reject all other login attempts:
|
||||
|
||||
```ini
|
||||
[SERVER-1]
|
||||
MODE: SERVER
|
||||
REG_ACL: PERMIT:311111,311222
|
||||
```
|
||||
|
||||
**Large but efficient range list.** Contiguous and adjacent ranges are merged at
|
||||
load time, so this…
|
||||
|
||||
```ini
|
||||
SUB_ACL: PERMIT:1000-1999,2000-2999,3000-3999,3120101
|
||||
```
|
||||
|
||||
…collapses internally to a single span `1000–3999` plus the single ID `3120101`.
|
||||
Write ranges however is clearest to *you*; the engine tidies them up.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
**Short version: turn ACLs on. They are fast.**
|
||||
|
||||
* All of the work of reading and organizing your ACLs happens **once, when
|
||||
HBlink3 starts** — not while traffic is flowing.
|
||||
* Checking a packet is near-instant, and it stays fast no matter how long your
|
||||
lists get. A list with a thousand entries is checked essentially as quickly as
|
||||
a list with ten. You will not see the difference on real traffic.
|
||||
* Runs of consecutive IDs are **combined** internally, so a config that looks
|
||||
huge often takes very little memory.
|
||||
|
||||
Where cost actually comes from — and it is small:
|
||||
|
||||
* **Every enabled ACL type adds one check per packet.** With GLOBAL + SYSTEM
|
||||
layering that's up to four checks per frame. Constant, tiny, predictable.
|
||||
* **Very large lists of thousands of individual (non-consecutive) IDs** use a
|
||||
little more memory and startup time, but checking them stays fast.
|
||||
|
||||
Practical guidance:
|
||||
|
||||
* Use ranges (`1000-2000`) instead of listing many consecutive IDs — it's less to
|
||||
type and smaller in memory.
|
||||
* If a stanza genuinely uses no ACLs, `USE_ACL: False` skips the subscriber/TGID
|
||||
checks entirely (registration is still enforced).
|
||||
* Don't hesitate to write expressive ACLs. The design specifically optimizes for
|
||||
exactly this; in practice the cost is negligible.
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
* **`PERMIT` is a whitelist — it denies everyone you didn't list.** The most
|
||||
common mistake is writing `PERMIT:3120101` intending "also allow 3120101" and
|
||||
accidentally locking out the entire rest of the world. Re-read [Section 2](#2-the-single-most-important-rule-what-happens-to-ids-you-didnt-list).
|
||||
* **Out-of-range entries stop HBlink3 at startup.** A subscriber/TGID entry above
|
||||
16776415, or a peer entry above 4294967295, triggers
|
||||
`ACL CREATION ERROR, VALUE OUT OF RANGE` and the program exits. Check your
|
||||
numbers if HBlink3 won't start after an ACL edit.
|
||||
* **Write ranges low-to-high** (`1000-2000`, not `2000-1000`). A reversed range
|
||||
will silently never match anything.
|
||||
* **`REG_ACL` ignores `USE_ACL`.** You cannot "disable" registration filtering by
|
||||
setting `USE_ACL: False`; set `REG_ACL: PERMIT:ALL` if you want it open.
|
||||
* **GLOBAL and SYSTEM ACLs are ANDed.** If a call is mysteriously dropped, check
|
||||
the GLOBAL stanza too — a global ACL can veto traffic a permissive system ACL
|
||||
would otherwise allow.
|
||||
* **The keys must exist even when unused.** Leave unused ACLs at `PERMIT:ALL`
|
||||
rather than deleting them.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to HBlink3. This is the first tagged release; it establishes a
|
||||
baseline of the current state rather than enumerating the project's full history.
|
||||
|
||||
## [3.0.0] — 2026-07-12
|
||||
|
||||
First tagged release. Highlights of the current state:
|
||||
|
||||
### Reporting & dashboard (event-driven overhaul)
|
||||
- The daemon→dashboard feed is event-driven newline-delimited JSON. Repeater
|
||||
connect/disconnect and live call events are pushed **as they happen**; the full
|
||||
config/bridge state is resent only as a slow periodic resync + heartbeat (it used
|
||||
to be a full push every interval).
|
||||
- Per-repeater **ping-loss** quality metric — surfaces a repeater that stays
|
||||
connected but drops keepalive pings (a lossy link → choppy audio), self-calibrated
|
||||
to each repeater's own ping cadence. Configured with `PING_LOSS_WINDOW` /
|
||||
`PING_LOSS_WARN` in `[GLOBAL]`; the dashboard golds a repeater's callsign at/above
|
||||
the warn threshold.
|
||||
- Canonical event vocabulary: `repeater_connected` / `repeater_disconnected`,
|
||||
`stream_start` / `stream_end`.
|
||||
- **Unix-socket transport** for a same-host dashboard (`REPORT_TRANSPORT=unix` +
|
||||
`REPORT_SOCKET`; dashboard side `HBLINK_TRANSPORT` / `HBLINK_SOCKET`), which
|
||||
retires the silently-severed-link failure class for local dashboards. Remote (TCP)
|
||||
dashboards gain TCP keepalive + a feed read-timeout so a dead link is detected.
|
||||
|
||||
### OpenBridge configuration model — **breaking**
|
||||
- OpenBridge systems are configured in a per-OBP table
|
||||
`OBP_BRIDGES = { <obp system> : { <bridge> : <TGID> } }` in `rules.py`, **not** as
|
||||
inline `BRIDGES` members. OBP entries carry no ON/OFF/TIMEOUT triggers or real
|
||||
timeslot (a trunk has no RF user to key them). The table doubles as the
|
||||
fail-closed ingress/egress filter; a one-TGID-to-two-bridges fork is a startup
|
||||
**ERROR**, a cross-OpenBridge renumber a **WARNING**.
|
||||
- Migration tool `tools/migrate_obp_rules.py` converts an old inline `rules.py`.
|
||||
|
||||
### System-mode terminology — **breaking**
|
||||
- `MASTER` → **`SERVER`**, `PEER` / `CLIENT` → **`OUTBOUND`** (`OPENBRIDGE`
|
||||
unchanged). Update the `MODE:` line of each stanza in `hblink.cfg`.
|
||||
|
||||
### Documentation
|
||||
- README (with an upgrade warning), INSTALL, CONFIGURING, ACLS, and the dashboard
|
||||
docs refreshed for all of the above.
|
||||
|
||||
[3.0.0]: https://github.com/n0mjs710/hblink3/releases/tag/v3.0.0
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
# Configuring HBlink3
|
||||
|
||||
HBlink3's conference bridge (`bridge.py`) is driven by two files:
|
||||
|
||||
| File | What it defines |
|
||||
|---|---|
|
||||
| `hblink.cfg` | The program itself: global timers, reporting, logging, alias lookups, and one stanza per **system** (a Server you host, an Outbound system you dial out as, or an OpenBridge link). |
|
||||
| `rules.py` | The **routing**: which systems talk to which, on what timeslot/talkgroup, and the talkgroup triggers/timers that turn bridges on and off. |
|
||||
|
||||
This document is the full field reference. The sample files (`hblink-SAMPLE.cfg`, `rules_SAMPLE.py`) carry only terse inline notes and point back here.
|
||||
|
||||
Both are read at startup; there is no live reload — change a file, restart the service.
|
||||
|
||||
---
|
||||
|
||||
# `hblink.cfg`
|
||||
|
||||
INI format (`configparser`). Section names in `[BRACKETS]`; `KEY: value` pairs. The four fixed sections (`[GLOBAL]`, `[REPORTS]`, `[LOGGER]`, `[ALIASES]`) must each appear once. Every **other** section is a *system*, named by you and identified by its `MODE`.
|
||||
|
||||
## `[GLOBAL]`
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `PATH` | Working directory for runtime files. `./` (the program's own directory) is normal. |
|
||||
| `PING_TIME` | Seconds between keepalive pings on Server and Outbound connections. |
|
||||
| `MAX_MISSED` | Missed pings before a connection is declared dead and torn down. |
|
||||
| `PING_LOSS_WINDOW` | Optional (default `5`, range `1`–`60`). Minutes over which the dashboard measures each repeater's **ping loss** — the share of expected keepalive pings that went missing. Larger = smoother / longer memory. Surfaces a repeater that stays connected but has a lossy link (choppy audio). A repeater that merely pings less often is not counted as lossy. |
|
||||
| `PING_LOSS_WARN` | Optional (default `5`). Ping-loss **percent** at or above which the dashboard turns a repeater's callsign gold. `0` disables the flag. |
|
||||
| `USE_ACL` | `True`/`False` top-level on/off switch for the **global** ACLs below. System-level ACLs are independent of this. |
|
||||
| `REG_ACL` | Registration ACL — which **repeater radio IDs** may log in to any Server you host. ACL syntax below. |
|
||||
| `SUB_ACL` | Subscriber ACL — which **subscriber IDs** (the person keying up) may pass traffic, globally. |
|
||||
| `TGID_TS1_ACL` / `TGID_TS2_ACL` | Talkgroup ACLs applied to timeslot 1 / timeslot 2 traffic, globally. |
|
||||
|
||||
### ACL syntax
|
||||
|
||||
Every ACL is one string: `ACTION:list`, where `ACTION` is `PERMIT` or `DENY` and `list` is comma-separated IDs and ranges, or the word `ALL`.
|
||||
|
||||
```
|
||||
PERMIT:ALL # allow everything
|
||||
DENY:1 # block ID 1, allow the rest
|
||||
DENY:1-5,3120101 # block the range 1–5 and the single ID 3120101
|
||||
PERMIT:3100-3199,9 # allow only this range and ID 9; deny everything else
|
||||
```
|
||||
|
||||
A global ACL and a system ACL both apply — traffic must pass **both**. Registration ACLs are only meaningful for `SERVER` systems (an Outbound or OpenBridge link never accepts registrations). See **[ACLS.md](ACLS.md)** for the full ACL model — PERMIT/DENY precedence, how ranges are matched, and worked examples.
|
||||
|
||||
## `[REPORTS]`
|
||||
|
||||
Feeds the real-time dashboard. See [`dashboard/`](dashboard/).
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `REPORT` | `True`/`False` — emit the event stream at all. |
|
||||
| `REPORT_INTERVAL` | Seconds between periodic status reports. |
|
||||
| `REPORT_TRANSPORT` | Optional (default `tcp`). `tcp` = listen on a TCP port (dashboard may be remote); `unix` = listen on a local Unix-domain socket instead. A same-box dashboard on a Unix socket can't be broken by a NIC flap or connection-tracking eviction, so use `unix` when the dashboard runs on the same host. |
|
||||
| `REPORT_PORT` | TCP port the report service listens on (when `REPORT_TRANSPORT` = `tcp`). |
|
||||
| `REPORT_SOCKET` | Filesystem path for the listening socket (when `REPORT_TRANSPORT` = `unix`), e.g. `/tmp/hblink3-report.sock`. Access is governed by filesystem permissions; `REPORT_CLIENTS` is not used on a Unix socket. |
|
||||
| `REPORT_CLIENTS` | Comma-separated client IPs allowed to connect (e.g. `127.0.0.1`) — TCP only. |
|
||||
|
||||
> The dashboard has matching `HBLINK_TRANSPORT` (`tcp`/`unix`) and `HBLINK_SOCKET` settings — see [dashboard/README.md](dashboard/README.md). Point the dashboard at the same transport the daemon serves.
|
||||
|
||||
## `[LOGGER]`
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `LOG_FILE` | Path to the log file. Empty ⇒ `/dev/null`. |
|
||||
| `LOG_HANDLERS` | Handler set, e.g. `console-timed`, `file-timed`, or both comma-joined. |
|
||||
| `LOG_LEVEL` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. |
|
||||
| `LOG_NAME` | Name tag prefixed to log lines. |
|
||||
|
||||
> Running under **systemd**? You can skip the log file entirely and rely on the journal:
|
||||
> set `LOG_FILE` to `/dev/null` (or leave it empty) and use a `console` handler — systemd
|
||||
> captures stdout into the journal (`journalctl -u hblink3`). Most installs are perfectly
|
||||
> fine doing exactly this.
|
||||
|
||||
## `[ALIASES]`
|
||||
|
||||
Maps DMR IDs to callsigns/names so logs read as `N0MJS` instead of `3120001`. The files come from radioid.net.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `USE_ALIASES` | `True`/`False`. When `False`, no alias files are loaded and logs show raw numeric IDs. Default `False`. |
|
||||
| `TRY_DOWNLOAD` | `True` fetches fresh files from the URLs below on startup and refreshes them past `STALE_DAYS`. `False` uses whatever is already on disk. |
|
||||
| `PATH` | Directory holding the alias JSON files. Must end in `/`. Point this at the dashboard's directory to share one copy. |
|
||||
| `PEER_FILE` / `SUBSCRIBER_FILE` / `TGID_FILE` | Filenames for the peer, subscriber, and talkgroup maps. `TGID_FILE` is optional. |
|
||||
| `PEER_URL` / `SUBSCRIBER_URL` | radioid.net download URLs used when `TRY_DOWNLOAD` is `True`. |
|
||||
| `STALE_DAYS` | Re-download after this many days. |
|
||||
|
||||
> ⚠️ **Memory warning — read before enabling aliases here.**
|
||||
> The full radioid.net subscriber database, expanded into HBlink's in-memory Python
|
||||
> dictionary, balloons to roughly **500 MB of RAM**. `bridge.py` (and `dmrlink`) *can*
|
||||
> load these files just to print callsigns in the log — but if you also run the
|
||||
> **dashboard** with aliases, you load the whole thing **twice: over 1 GB of RAM, purely
|
||||
> for prettier logs.**
|
||||
>
|
||||
> **Recommendation:** enable aliases in the **dashboard only**, and leave
|
||||
> `USE_ALIASES: False` here (or omit the files) for `bridge.py` / `dmrlink`. Turn them on
|
||||
> in the main program **only** if the subscriber file has been **heavily filtered** to a
|
||||
> much smaller size first — use the dashboard's `FILTER_COUNTRIES` (in `dashboard/config.py`),
|
||||
> which writes only the selected countries to disk; both programs then load that smaller
|
||||
> file. Filtered to a country or two, the footprint drops to roughly **150 MB**; it's the
|
||||
> *unfiltered* global load in two processes that costs the gigabyte and is almost never
|
||||
> worth it.
|
||||
|
||||
## System stanzas
|
||||
|
||||
Any section that isn't one of the four above is a *system*. It is loaded only if `ENABLED: True`, and its `MODE` selects one of three types:
|
||||
|
||||
| `MODE` | Role |
|
||||
|---|---|
|
||||
| `SERVER` | You are the **Server**: Clients — repeaters, hotspots, or another HBlink acting as a Client — connect *in* to you. (Older DMR docs call a Server a "master"; same thing — a master **is** a Server.) |
|
||||
| `OUTBOUND` | You are a **Client**: HBlink masquerades as a repeater/hotspot and dials *out* to someone else's Server. |
|
||||
| `OPENBRIDGE` | A **Server-to-Server** OpenBridge link — no Client role, no login handshake. |
|
||||
|
||||
> 🛑 **The section name is load-bearing — get it exactly right.** The name you give a
|
||||
> system (e.g. `[SERVER-1]`) is the exact string `rules.py` references in its `SYSTEM`
|
||||
> field. **If any `SYSTEM` in `rules.py` does not match a system name in `hblink.cfg`,
|
||||
> `bridge.py` will not start at all.** Keep the two files in sync, character for character.
|
||||
|
||||
### `MODE: SERVER` (be a Server)
|
||||
|
||||
You are the Server; Clients (repeaters, hotspots, or another HBlink acting as a Client) connect in.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `ENABLED` | `False` ⇒ the stanza is skipped entirely. |
|
||||
| `REPEAT` | `True` repeats traffic between Clients connected to *this* Server (normal). `False` makes it a pure bridge feed. |
|
||||
| `MAX_REPEATERS` | Cap on simultaneously logged-in Clients. |
|
||||
| `IP` | Local bind address (`0.0.0.0` = all interfaces). |
|
||||
| `PORT` | Local UDP port Clients connect to. |
|
||||
| `PASSPHRASE` | Shared login secret Clients must present. |
|
||||
| `GROUP_HANGTIME` | Seconds a slot stays reserved to the current talkgroup after a transmission, so quick replies aren't lost to contention. |
|
||||
| `USE_ACL` | Enable this system's own ACLs. |
|
||||
| `REG_ACL` | Which radio IDs may register to this Server. |
|
||||
| `SUB_ACL` | Subscriber ACL for this system. |
|
||||
| `TGID_TS1_ACL` / `TGID_TS2_ACL` | Per-timeslot talkgroup ACLs for this system. |
|
||||
|
||||
### `MODE: OUTBOUND` (be a Client)
|
||||
|
||||
HBlink masquerades as a repeater/hotspot and dials out to another Server. Most fields are the homebrew configuration/login packet this Client presents to the far Server — cosmetic to HBlink, but visible on the far end.
|
||||
|
||||
> 🛑 **BrandMeister will not accept this.** BrandMeister prohibits inbound Client
|
||||
> (`OUTBOUND`) connections for bridging and **requires OpenBridge** instead. To link
|
||||
> HBlink to BrandMeister, use `MODE: OPENBRIDGE`, not `OUTBOUND`.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `ENABLED` | `False` ⇒ skipped. |
|
||||
| `LOOSE` | `True` relaxes inbound validation: frames are accepted even if the source radio ID in the header doesn't match this connection's `RADIO_ID`. Needed for some Servers; leave `False` when the far end is strict. |
|
||||
| `IP` / `PORT` | Our own local bind address/port. |
|
||||
| `SERVER_IP` / `SERVER_PORT` | The far Server's address/port we dial. |
|
||||
| `PASSPHRASE` | Login secret for the far Server. |
|
||||
| `RADIO_ID` | The DMR ID we log in as. |
|
||||
| `CALLSIGN` | Our callsign (8 chars, space-padded). |
|
||||
| `RX_FREQ` / `TX_FREQ` | Reported RX/TX frequencies (Hz, 9 digits). |
|
||||
| `TX_POWER` | Reported TX power. |
|
||||
| `COLORCODE` | Reported DMR color code. |
|
||||
| `LATITUDE` / `LONGITUDE` / `HEIGHT` / `LOCATION` / `DESCRIPTION` / `URL` | Reported location/metadata. |
|
||||
| `SLOTS` | Slot capability reported to the Server. |
|
||||
| `SOFTWARE_ID` / `PACKAGE_ID` | Reported software/package identifiers. |
|
||||
| `OPTIONS` | Free-form options string some Servers parse for per-connection settings. Leave empty if unused. |
|
||||
| `GROUP_HANGTIME` | As above. |
|
||||
| `USE_ACL` / `SUB_ACL` / `TGID_TS1_ACL` / `TGID_TS2_ACL` | This system's ACLs. (No `REG_ACL` — a Client doesn't accept registrations.) |
|
||||
|
||||
### `MODE: OPENBRIDGE` (server-to-server link)
|
||||
|
||||
OpenBridge is a Server-to-Server (both ends equal), always-TS1 link authenticated by a shared HMAC key and the source socket — no login/registration handshake.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `ENABLED` | `False` ⇒ skipped. |
|
||||
| `IP` / `PORT` | Our own local bind address/port. |
|
||||
| `TARGET_IP` / `TARGET_PORT` | The far OpenBridge server's address/port. |
|
||||
| `PASSPHRASE` | Shared HMAC-SHA1 key. **Must match exactly on both ends** — this plus the source socket *is* the authentication. |
|
||||
| `NETWORK_ID` | A DMR-ID-shaped number identifying this server. By convention it is stamped into every outgoing frame's "Repeater ID" field. |
|
||||
| `PRESERVE_SOURCE_PEER` | `True` forwards the **originating** peer ID in that Repeater-ID field instead of overwriting it with `NETWORK_ID`. The field is unvalidated (auth is the HMAC + source socket) and used only for logging/reporting, so this simply preserves a call's true source across the link. Default `False` (spec-conventional). Most useful when **both** ends enable it. |
|
||||
| `BOTH_SLOTS` | `True` lets unit (private) calls use both slots; group traffic is always TS1. **🛑 Only HBlink is known to accept this. No other OpenBridge server (BrandMeister, DMR+, etc.) accepts both-slots traffic — set `True` only on HBlink-to-HBlink links, and leave it `False` everywhere else.** |
|
||||
| `USE_ACL` / `SUB_ACL` | This link's subscriber ACL. |
|
||||
| `TGID_ACL` | Talkgroup ACL (TS1 only — note the single-slot name, unlike the `SERVER`/`OUTBOUND` `TGID_TS1_ACL`/`TGID_TS2_ACL`). |
|
||||
|
||||
---
|
||||
|
||||
# `rules.py`
|
||||
|
||||
Defines the conference bridges — the actual routing. Think "bridge groups" or "reflectors": every system marked active on a bridge exchanges traffic with every other active system on that same bridge. It is **not** end-to-end; each system is activated on each bridge independently.
|
||||
|
||||
## `BRIDGES`
|
||||
|
||||
A dict. Each **key** is an arbitrary bridge name (`'WORLDWIDE'`, `'STATEWIDE'`, …). Each **value** is a list of system entries — one per participating system — each a dict of:
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `SYSTEM` | The system's section name from `hblink.cfg`. **Must match exactly.** |
|
||||
| `TS` | Timeslot (`1` or `2`) this rule matches. (XLX links should always use TS 2.) |
|
||||
| `TGID` | Talkgroup ID this rule matches. (XLX links should always use TG 9.) |
|
||||
| `ACTIVE` | `True`/`False` — is this system currently passing traffic on this bridge? (Triggers below can flip it at runtime.) |
|
||||
| `TO_TYPE` | Timeout behavior: `'ON'` = auto-turn-**off** after `TIMEOUT`; `'OFF'` = auto-turn-**on** after `TIMEOUT`; anything else (use `'NONE'`) = no timer. |
|
||||
| `TIMEOUT` | Timer length in **minutes**. (Minutes only — timers cost performance.) |
|
||||
| `ON` | List of talkgroup IDs that, when keyed, **activate** this system on the bridge. Always a list, even for one (`[2]`); use `[]` for none. |
|
||||
| `OFF` | List of talkgroup IDs that **deactivate** it. |
|
||||
| `RESET` | List of talkgroup IDs that reset a running timer without otherwise changing state. Only needed when your voice TGID differs from your trigger TGIDs; otherwise `[]`. |
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
BRIDGES = {
|
||||
'WORLDWIDE': [
|
||||
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 1, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2], 'OFF': [9, 10], 'RESET': []},
|
||||
{'SYSTEM': 'CLIENT-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2], 'OFF': [9, 10], 'RESET': []},
|
||||
],
|
||||
'STATEWIDE': [
|
||||
{'SYSTEM': 'SERVER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4], 'OFF': [7, 10], 'RESET': []},
|
||||
{'SYSTEM': 'CLIENT-2', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4], 'OFF': [7, 10], 'RESET': []},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Here anything on `SERVER-1` TS1/TG1 reaches `CLIENT-1` TS1/TG3100 and vice-versa (talkgroups are rewritten per entry). On `WORLDWIDE`, keying TG2 turns a system on for 2 minutes; TG9 or TG10 turns it off. `STATEWIDE` has no timers.
|
||||
|
||||
## `OBP_BRIDGES`
|
||||
|
||||
**OpenBridge (`MODE: OPENBRIDGE`) systems are configured here, not as inline `BRIDGES` members.** An OpenBridge is a point-to-point trunk that passes talkgroups by TGID — it has no RF user to key `ON`/`OFF`/`TIMEOUT` triggers and no meaningful timeslot — so it declares a compact per-OBP table mapping **bridge → TGID** instead:
|
||||
|
||||
```python
|
||||
OBP_BRIDGES = {
|
||||
'VESTA_OBP': { # an OPENBRIDGE system from hblink.cfg
|
||||
'WORLDWIDE': 1,
|
||||
'STATEWIDE': 3129,
|
||||
'ENGLISH': (13, 2), # optional (TGID, TS) form; TS defaults to 1 otherwise
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Read one row as **"on this OpenBridge, this TGID *is* this bridge."** That single fact is the route in **both** directions and the filter: at startup `bridge.py` expands each row into a bridge member, so a TGID **not** listed for an OBP is simply never routed — dropped **fail-closed**, in and out. There is no separate ACL.
|
||||
|
||||
| Rule | Behavior |
|
||||
|---|---|
|
||||
| TS | Defaults to `1` (OpenBridge's "no timeslot" placeholder). Override with the `(TGID, TS)` tuple form. |
|
||||
| Triggers | None — `TO_TYPE`/`TIMEOUT`/`ON`/`OFF` don't apply to a trunk and are omitted. |
|
||||
| OBP system left as an inline `BRIDGES` member | **Startup ERROR** (move it into `OBP_BRIDGES`). |
|
||||
| Same OBP maps one TGID → two bridges (**ingress fork**) | **Startup ERROR** — it would duplicate the stream. |
|
||||
| A bridge carrying **different** TGIDs on two OBPs (**renumber in transit**) | Allowed, logs a **WARNING** — usually a typo, occasionally intentional at a network boundary. |
|
||||
|
||||
`OBP_BRIDGES` is optional — omit it (or leave it `{}`) if you run no OpenBridges.
|
||||
|
||||
### Migrating from the old inline form
|
||||
|
||||
Earlier HBlink3 configured OpenBridge systems as inline `BRIDGES` members. To convert an existing `rules.py`, run the migration tool from the repo root:
|
||||
|
||||
```bash
|
||||
python tools/migrate_obp_rules.py --config hblink.cfg --rules rules.py --out rules.py.migrated
|
||||
```
|
||||
|
||||
It reads `hblink.cfg` to learn which systems are `OPENBRIDGE`, lifts each inline OBP member into `OBP_BRIDGES`, prints a summary of what moved, flags any ingress-fork **ERROR** or renumber **WARNING**, and writes a new file (it never overwrites your `rules.py`). Review the output — comments from the original are not carried over — then replace `rules.py` with it.
|
||||
|
||||
## `UNIT`
|
||||
|
||||
```python
|
||||
UNIT = ['SERVER-1', 'CLIENT-2']
|
||||
```
|
||||
|
||||
A flat list of system names that should bridge **unit-to-unit (private) calls** to each other. Systems not listed here pass group calls per `BRIDGES` but ignore private calls.
|
||||
|
||||
## Validating
|
||||
|
||||
`rules.py` is executable Python. Run it directly to syntax-check before restarting the service:
|
||||
|
||||
```
|
||||
python3 rules.py
|
||||
```
|
||||
|
||||
It pretty-prints `BRIDGES` and `UNIT` if the file parses; a traceback means a syntax error to fix.
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# Installing HBlink3
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python **3.8** or newer
|
||||
- Linux (developed and run on Linux; other platforms are untested)
|
||||
- Install and run as the **same user account**
|
||||
|
||||
## 1. Clone and create a virtual environment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/n0mjs710/hblink3.git
|
||||
cd hblink3
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
The optional web [dashboard](dashboard/) can share this same virtualenv — it only
|
||||
adds FastAPI and Uvicorn on top of the core. To include it:
|
||||
|
||||
```bash
|
||||
pip install -r dashboard/requirements.txt # or: pip install -e ".[dashboard]"
|
||||
```
|
||||
|
||||
## 2. Configure
|
||||
|
||||
Copy the sample config (the live name is git-ignored, so updates won't overwrite your edits):
|
||||
|
||||
```bash
|
||||
cp hblink-SAMPLE.cfg hblink.cfg
|
||||
```
|
||||
|
||||
Edit `hblink.cfg`:
|
||||
|
||||
- **[GLOBAL]** — ping timing and the default access-control lists.
|
||||
- **[REPORTS]** — enable the TCP feed for a dashboard (listen port and allowed client IPs).
|
||||
- **[LOGGER]** — log file, handlers, and level.
|
||||
- **[ALIASES]** — optional download of repeater/subscriber ID files from radioid.net.
|
||||
- **System stanzas** — one per connection. Set `MODE` to:
|
||||
- `SERVER` — accept incoming repeaters/hotspots (formerly `MASTER`)
|
||||
- `OUTBOUND` — connect outward to another server (formerly `PEER`/`CLIENT`)
|
||||
- `OPENBRIDGE` — link to Brandmeister / DMR+ (IPSC2)
|
||||
|
||||
Duplicate a stanza for each system; the stanza name (e.g. `[SERVER-1]`) must be unique.
|
||||
|
||||
If you are running `bridge.py`, also create the rules file:
|
||||
|
||||
```bash
|
||||
cp rules_SAMPLE.py rules.py
|
||||
```
|
||||
|
||||
`rules.py` defines:
|
||||
|
||||
- **`BRIDGES`** — each conference bridge and the system / timeslot / talkgroup that belongs to it, with optional ON/OFF/RESET trigger talkgroups and timeouts.
|
||||
- **`OBP_BRIDGES`** — OpenBridge (`OPENBRIDGE`) systems only: a per-OBP `{bridge: TGID}` table (not inline `BRIDGES` members). See [CONFIGURING.md](CONFIGURING.md#obp_bridges).
|
||||
- **`UNIT`** — the systems permitted to exchange private (unit) calls.
|
||||
|
||||
Every system named in `rules.py` must exist and be enabled in `hblink.cfg`. **Upgrading from an older HBlink3?** OpenBridge members used to live inline in `BRIDGES`; convert your file with `python tools/migrate_obp_rules.py` (see [CONFIGURING.md](CONFIGURING.md#obp_bridges)).
|
||||
|
||||
## 3. Run
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python bridge.py -c hblink.cfg -r rules.py # conference-bridge router (main app)
|
||||
# or
|
||||
python bridge_all.py -c hblink.cfg # forward-everything proxy
|
||||
# or
|
||||
python hblink.py -c hblink.cfg # protocol core, standalone server/outbound
|
||||
```
|
||||
|
||||
`-c` / `-r` default to `hblink.cfg` and `rules.py` in the program directory. `-l LEVEL` overrides the configured log level.
|
||||
|
||||
## 4. Run as a service (systemd)
|
||||
|
||||
A sample unit, `hblink3.service`, is provided (it targets `bridge.py`):
|
||||
|
||||
```bash
|
||||
sudo cp hblink3.service /etc/systemd/system/
|
||||
sudoedit /etc/systemd/system/hblink3.service # set User/Group and the install paths
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hblink3
|
||||
journalctl -u hblink3 -f # follow the log
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
git pull
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt # in case dependencies changed
|
||||
sudo systemctl restart hblink3
|
||||
```
|
||||
|
||||
`hblink.cfg` and `rules.py` are git-ignored and are not touched by `git pull`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The legacy voice utilities (`playback.py`, `play_ambe.py`) are **not** migrated to asyncio and will not run against the current core.
|
||||
@@ -1,30 +1,146 @@
|
||||
## PROJECT: Open Source HomeBrew Repeater Proctol Client/Master. ##
|
||||
# HBlink3
|
||||
|
||||
**UPDATES:**
|
||||
HBlink3 is an open-source implementation of the DMR **HomeBrew Repeater Protocol (HBP)** in Python 3. It acts as an HBP server and/or client and routes calls between MMDVM-based DMR systems, working as a **transit / conference-bridge router** — traffic is selectively routed from ingress systems to egress systems according to a rules file. It can also connect to other HBlink3 and similar systems via **OpenBridge**.
|
||||
|
||||
No longer updated. This project is depreciated. HBlink4 is the current version that is in active development
|
||||
> **Which HBlink?** HBlink3 is "system-oriented" and built for **transit routing and conference bridging** between networks. Its companion, **HBlink4** (by the same author), is a repeater-oriented **endpoint server** for running a single end-point network with no granudlar transit routing. Pick the one that matches your role.
|
||||
|
||||
**PURPOSE:** Thanks to the work of Jonathan Naylor, G4KLX; Hans Barthen, DL5DI; Torsten Shultze, DG1HT we have an open protocol for internetworking DMR repeaters. Unfortunately, there's no generic client and/or master stacks. This project is to build an open-source, python-based implementation. You are free to use this software however you want, however we ask that you provide attribution in some public venue (such as project, club, organization web site). This helps us see where the software is in use and track how it is used.
|
||||
> ⚠️ **Upgrading? Two breaking config changes to know about.**
|
||||
> 1. **OpenBridge routing moved** — `MODE: OPENBRIDGE` systems are no longer inline `BRIDGES` members in `rules.py`; they now live in a dedicated **`OBP_BRIDGES`** table. Old configs stop with a clear startup error. Run **`python tools/migrate_obp_rules.py`** to convert your `rules.py` automatically, or see [CONFIGURING.md](CONFIGURING.md#obp_bridges).
|
||||
> 2. **System modes renamed** — `MASTER`→**`SERVER`** and `PEER`/`CLIENT`→**`OUTBOUND`** (`OPENBRIDGE` unchanged). Update the `MODE:` line of each stanza in `hblink.cfg`.
|
||||
>
|
||||
> Your `hblink.cfg`/`rules.py` are git-ignored, so a `git pull` won't touch them — but the new code won't accept the old values. Non-OpenBridge configs need only change (2).
|
||||
|
||||
For those who will ask: This is a piece of software that implements an open-source, amateur radio networking protocol. It is not a network. It is not intended to be a network. It is not intended to replace or circumvent a network. People do those things, code doesn't.
|
||||
|
||||
**PROPERTY:**
|
||||
This work represents the author's interpretation of the HomeBrew Repeater Protocol, based on the 2015-07-26 documents from DMRplus, "IPSC Protocol Specs for homebrew DMR repeater" as written by Jonathan Naylor, G4KLX; Hans Barthen, DL5DI; Torsten Shultze, DG1HT, also licenced under Creative Commons BY-NC-SA license.
|
||||
## Applications
|
||||
|
||||
**WARRANTY**
|
||||
None. The owners of this work make absolutely no warranty, express or implied. Use this software at your own risk.
|
||||
| Program | Purpose |
|
||||
|---|---|
|
||||
| `bridge.py` | The main application: a configurable conference-bridge call router with dynamic on/off triggering, timeouts, and private (unit) call routing. |
|
||||
| `bridge_all.py` | A simple proxy that forwards all traffic between every configured system — makes several repeaters appear as one. |
|
||||
| `hblink.py` | The protocol core. Runs standalone as a server/outbound system for testing, and is the module the applications are built on. |
|
||||
|
||||
**PRE-REQUISITE KNOWLEDGE:**
|
||||
This document assumes the reader is familiar with Linux/UNIX, the Python programming language and DMR.
|
||||
## Features
|
||||
|
||||
**MORE DOCUMENTATION TO COME**
|
||||
- HBP **server** and **outbound** (client) modes for MMDVM repeaters and hotspots
|
||||
- **OpenBridge** connectivity to other transit call routers, including Brandmeister / DMR+ (IPSC2)
|
||||
- Rules-based **conference-bridge routing** with dynamic ON/OFF/RESET talkgroup triggers and timeouts, including the ability to use TGIDs as in-band signalling to trigger dynamic connections -- sometimes referred to as "dial-a-talkgroup".
|
||||
- **Private (unit) call** routing using a learned subscriber-to-system map that only floods systems configured for unit calls, and only until the target unit's system is located. It then prunes re-transmission to the system with the target unit.
|
||||
- Layered **access control lists** — registration, subscriber, and per-timeslot talkgroup
|
||||
- TCP **reporting feed** for external dashboards
|
||||
- Built on Python **`asyncio`** — no external networking framework
|
||||
|
||||
***0x49 DE N0MJS***
|
||||
## Quick start
|
||||
|
||||
Copyright (C) 2016-2020 Cortney T. Buffington, N0MJS n0mjs@me.com
|
||||
```bash
|
||||
git clone https://github.com/n0mjs710/hblink3.git
|
||||
cd hblink3
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp hblink-SAMPLE.cfg hblink.cfg # edit for your systems
|
||||
cp rules_SAMPLE.py rules.py # edit your bridges (bridge.py only)
|
||||
python bridge.py -c hblink.cfg -r rules.py
|
||||
```
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version.
|
||||
See **[INSTALL.md](INSTALL.md)** for full setup, configuration, and running as a systemd service.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
## Configuration
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
- **`hblink.cfg`** — global settings, reporting, logging, alias downloads, and one stanza per system (`SERVER`, `OUTBOUND`, or `OPENBRIDGE`). Start from `hblink-SAMPLE.cfg`.
|
||||
- **`rules.py`** — (`bridge.py` only) defines the conference bridges, the systems/talkgroups/timeslots that belong to each, and the systems permitted to pass unit calls. Start from `rules_SAMPLE.py`.
|
||||
|
||||
Your `hblink.cfg` and `rules.py` are git-ignored, so `git pull` won't overwrite them.
|
||||
|
||||
### Alias files
|
||||
|
||||
HBlink3 resolves numeric IDs to human-readable names in logs and in the dashboard. The
|
||||
`[ALIASES]` section of `hblink.cfg` controls where these files live and how they are obtained.
|
||||
|
||||
**Peer and subscriber aliases** (`peer_ids.json`, `subscriber_ids.json`) are downloaded
|
||||
automatically from RadioID.net when `TRY_DOWNLOAD: True` is set and the files are older
|
||||
than `STALE_DAYS`. No manual action is needed for these.
|
||||
|
||||
**Talkgroup aliases** (`talkgroup_ids.json`) have no automatic download source. This file
|
||||
must be created and maintained by the operator. Copy `talkgroup_ids_SAMPLE.json` to the
|
||||
path configured in `[ALIASES]` (default `./`) and rename it to match `TGID_FILE`
|
||||
(default `talkgroup_ids.json`), then edit it to reflect the talkgroups used on your network.
|
||||
|
||||
The format is a JSON object with a single key containing a list of records, each with an
|
||||
`id` field (integer talkgroup number) and a `callsign` field (display name). The field is
|
||||
named `callsign` because the same parser in `dmr_utils3` handles peer, subscriber, and
|
||||
talkgroup files alike — for talkgroups it simply holds the human-readable name. The outer
|
||||
key name can be anything, but it must be the first and only key in the object — the parser
|
||||
takes the value of the first key it finds:
|
||||
|
||||
```json
|
||||
{
|
||||
"talkgroups": [
|
||||
{"id": 3100, "callsign": "Nationwide"},
|
||||
{"id": 3170001, "callsign": "KS Statewide"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The outer key name (`"talkgroups"` above) can be anything — only the list it contains is
|
||||
used. Talkgroup IDs not present in the file are displayed as their raw numeric value.
|
||||
|
||||
## Dashboard
|
||||
|
||||
A real-time web dashboard lives in [`dashboard/`](dashboard/) — a separate program
|
||||
with a modern dark UI showing server/outbound/OpenBridge systems, conference-bridge
|
||||
state, and a live call log. Enable HBlink3's reporting feed (`[REPORTS]` in
|
||||
`hblink.cfg`) and see [dashboard/README.md](dashboard/README.md) to run it. the dashboard
|
||||
is kept separate to keep performance of the forwarding hot path free from unecessary
|
||||
loads.
|
||||
|
||||
## DMR Talker Alias is not preserved across bridges
|
||||
|
||||
When `bridge.py` routes a call to a system with a different TGID or timeslot, it
|
||||
rewrites the Link Control (LC) word in every forwarded DMR frame — voice header,
|
||||
voice terminator, and the embedded LC carried in voice bursts B–E.
|
||||
|
||||
The consequence is that **DMR Talker Alias data embedded in voice bursts is
|
||||
destroyed during bridging.** Talker Alias occupies the same embedded LC slots
|
||||
(FLCO 0x04–0x07 in bursts B–E) that the bridge overwrites with the translated
|
||||
call LC.
|
||||
|
||||
This was a consious design decision to ensure every superframe carries full LC
|
||||
information -- preferencing voice quality and integrity over all else.
|
||||
|
||||
**Future:** Currently, most hams appear to prefer/additinoally use a static
|
||||
database for ID to callsign mapping. Inclusion of Talker Alias may be
|
||||
considered in the future.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python, at least, **3.8+** (Linux recommended)
|
||||
- `bitarray`, `dmr_utils3` — see `requirements.txt`
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS — n0mjs@me.com
|
||||
|
||||
Licensed under the **GNU GPLv3**; see [LICENSE.txt](LICENSE.txt). You may use this software freely, but please credit the project somewhere public (club, organization, or project site) so we can see where it's in use.
|
||||
|
||||
### No Support Is Provided
|
||||
|
||||
This is not commercial software. It is provided free of charge. The author(s)
|
||||
received no compensation for creating and maintaining it. Countless hours over
|
||||
many years have gone into the this. If you have problems, the author will try
|
||||
to help if possible, please have no expectations for support. There is no online
|
||||
group, such as DVSwitch or groups.io that is an "official" outlet for information.
|
||||
The only definitive source of information is me. Beware of others claiming to
|
||||
be authoritative. User-based mutual support is great, and I'm all for it. But
|
||||
please understand, this is what they are, and I have not sanctioned anyone to be
|
||||
the "home" of my software packages.
|
||||
|
||||
### GitHub "Issues"
|
||||
|
||||
Do not use GitHub issues for support. Genuine bugs are accepted as issues. Before
|
||||
opening one, make sure that it is a true problem with the software and not merely
|
||||
a misconfiguration, or contention around a feature that was not supported. Isssues
|
||||
should never be used to ask for or recommend features. Issues that do not include
|
||||
complete details, relevent tracebacks, error messages, configuration snippets,
|
||||
operatrional conditions surrounding the event, etc. will be closed without action.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
The HomeBrew Repeater Protocol is the work of Jonathan Naylor (G4KLX), Hans Barthen (DL5DI), and Torsten Schultze (DG1HT). This project is the author's clean-room interpretation of that protocol.
|
||||
+34
-33
@@ -35,20 +35,14 @@ a lot of sense, and has the potential to do bad things.
|
||||
|
||||
# Python modules we need
|
||||
import sys
|
||||
from bitarray import bitarray
|
||||
from time import time
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
|
||||
# Twisted is pretty important, so I keep it separate
|
||||
from twisted.internet.protocol import Factory, Protocol
|
||||
from twisted.protocols.basic import NetstringReceiver
|
||||
from twisted.internet import reactor, task
|
||||
import asyncio
|
||||
|
||||
# Things we import from the main hblink module
|
||||
from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, reportFactory, REPORT_OPCODES, config_reports, mk_aliases, acl_check
|
||||
from dmr_utils3.utils import bytes_3, int_id, get_alias
|
||||
from dmr_utils3 import decode, bptc, const
|
||||
import config
|
||||
import log
|
||||
import const
|
||||
@@ -95,7 +89,7 @@ class bridgeallSYSTEM(HBSYSTEM):
|
||||
},
|
||||
2: {
|
||||
'RX_START': time(),
|
||||
'RX:LOSS': 0,
|
||||
'RX_LOSS': 0,
|
||||
'RX_SEQ': 0,
|
||||
'RX_RFS': b'\x00',
|
||||
'TX_RFS': b'\x00',
|
||||
@@ -263,33 +257,40 @@ if __name__ == '__main__':
|
||||
logger.info('\n\nCopyright (c) 2013, 2014, 2015, 2016, 2018, 2019\n\tThe Regents of the K0USY Group. All rights reserved.\n')
|
||||
logger.debug('Logging system started, anything from here on gets logged')
|
||||
|
||||
# Set up the signal handler
|
||||
def sig_handler(_signal, _frame):
|
||||
logger.info('SHUTDOWN: BRIDGE_ALL IS TERMINATING WITH SIGNAL %s', str(_signal))
|
||||
hblink_handler(_signal, _frame)
|
||||
logger.info('SHUTDOWN: ALL SYSTEM HANDLERS EXECUTED - STOPPING REACTOR')
|
||||
reactor.stop()
|
||||
|
||||
# Set signal handers so that we can gracefully exit if need be
|
||||
for sig in [signal.SIGTERM, signal.SIGINT]:
|
||||
signal.signal(sig, sig_handler)
|
||||
|
||||
# Create the name-number mapping dictionaries
|
||||
peer_ids, subscriber_ids, talkgroup_ids = mk_aliases(CONFIG)
|
||||
|
||||
# INITIALIZE THE REPORTING LOOP
|
||||
report_server = config_reports(CONFIG, reportFactory)
|
||||
# The asyncio entry point: signal handling, reporting, and a UDP endpoint for
|
||||
# each enabled system, then wait for shutdown.
|
||||
async def async_main():
|
||||
loop = asyncio.get_running_loop()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
# HBlink instance creation
|
||||
logger.info('HBlink \'bridge_all.py\' -- SYSTEM STARTING...')
|
||||
for system in CONFIG['SYSTEMS']:
|
||||
if CONFIG['SYSTEMS'][system]['ENABLED']:
|
||||
if CONFIG['SYSTEMS'][system]['MODE'] == 'OPENBRIDGE':
|
||||
logger.critical('%s FATAL: Instance is mode \'OPENBRIDGE\', \n\t\t...Which would be tragic for Bridge All, since it carries multiple call\n\t\tstreams simultaneously. bridge_all.py onlyl works with MMDVM-based systems', system)
|
||||
sys.exit('bridge_all.py cannot function with systems that are not MMDVM devices. System {} is configured as an OPENBRIDGE'.format(system))
|
||||
else:
|
||||
systems[system] = bridgeallSYSTEM(system, CONFIG, report_server)
|
||||
reactor.listenUDP(CONFIG['SYSTEMS'][system]['PORT'], systems[system], interface=CONFIG['SYSTEMS'][system]['IP'])
|
||||
logger.debug('%s instance created: %s, %s', CONFIG['SYSTEMS'][system]['MODE'], system, systems[system])
|
||||
def shutdown(signum):
|
||||
logger.info('SHUTDOWN: BRIDGE_ALL IS TERMINATING WITH SIGNAL %s', signum)
|
||||
hblink_handler(signum, None)
|
||||
logger.info('SHUTDOWN: ALL SYSTEM HANDLERS EXECUTED - STOPPING')
|
||||
stop_event.set()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, shutdown, sig)
|
||||
|
||||
reactor.run()
|
||||
# INITIALIZE THE REPORTING LOOP
|
||||
report_server = config_reports(CONFIG, reportFactory)
|
||||
|
||||
# HBlink instance creation
|
||||
logger.info('HBlink \'bridge_all.py\' -- SYSTEM STARTING...')
|
||||
for system in CONFIG['SYSTEMS']:
|
||||
if CONFIG['SYSTEMS'][system]['ENABLED']:
|
||||
if CONFIG['SYSTEMS'][system]['MODE'] == 'OPENBRIDGE':
|
||||
logger.critical('%s FATAL: Instance is mode \'OPENBRIDGE\', \n\t\t...Which would be tragic for Bridge All, since it carries multiple call\n\t\tstreams simultaneously. bridge_all.py onlyl works with MMDVM-based systems', system)
|
||||
sys.exit('bridge_all.py cannot function with systems that are not MMDVM devices. System {} is configured as an OPENBRIDGE'.format(system))
|
||||
else:
|
||||
systems[system] = bridgeallSYSTEM(system, CONFIG, report_server)
|
||||
await loop.create_datagram_endpoint(
|
||||
lambda s=systems[system]: s,
|
||||
local_addr=(CONFIG['SYSTEMS'][system]['IP'], CONFIG['SYSTEMS'][system]['PORT']))
|
||||
logger.debug('%s instance created: %s, %s', CONFIG['SYSTEMS'][system]['MODE'], system, systems[system])
|
||||
|
||||
await stop_event.wait()
|
||||
|
||||
asyncio.run(async_main())
|
||||
@@ -54,49 +54,64 @@ def process_acls(_config):
|
||||
|
||||
# System level ACLs
|
||||
for system in _config['SYSTEMS']:
|
||||
# Registration ACLs (which make no sense for peer systems)
|
||||
if _config['SYSTEMS'][system]['MODE'] == 'MASTER':
|
||||
# Registration ACLs (which make no sense for outbound systems)
|
||||
if _config['SYSTEMS'][system]['MODE'] == 'SERVER':
|
||||
_config['SYSTEMS'][system]['REG_ACL'] = acl_build(_config['SYSTEMS'][system]['REG_ACL'], const.PEER_MAX)
|
||||
|
||||
# Subscriber and TGID ACLs (valid for all system types)
|
||||
for acl in ['SUB_ACL', 'TG1_ACL', 'TG2_ACL']:
|
||||
_config['SYSTEMS'][system][acl] = acl_build(_config['SYSTEMS'][system][acl], const.ID_MAX)
|
||||
|
||||
# Create an access control list that is programatically useable from human readable:
|
||||
# Merge a list of (start, end) ranges into a minimal sorted list of disjoint
|
||||
# ranges. Required so acl_check can locate a match with a single bisect.
|
||||
def merge_ranges(_ranges):
|
||||
merged = []
|
||||
for start, end in sorted(_ranges):
|
||||
if merged and start <= merged[-1][1] + 1:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
# Create an access control list that is programatically usable from human readable.
|
||||
# Single IDs go in a set (O(1) membership); ranges are merged and split into
|
||||
# parallel sorted (starts, ends) tuples so acl_check can bisect them (O(log n)).
|
||||
# ORIGINAL: 'DENY:1-5,3120101,3120124'
|
||||
# PROCESSED: (False, set([(1, 5), (3120124, 3120124), (3120101, 3120101)]))
|
||||
# PROCESSED: (False, frozenset({3120101, 3120124}), (1,), (5,))
|
||||
def acl_build(_acl, _max):
|
||||
if not _acl:
|
||||
return(True, set((const.ID_MIN, _max)))
|
||||
return (True, frozenset(), (const.ID_MIN,), (_max,))
|
||||
|
||||
acl = [] #set()
|
||||
singles = set()
|
||||
ranges = []
|
||||
sections = _acl.split(':')
|
||||
|
||||
if sections[0] == 'PERMIT':
|
||||
action = True
|
||||
else:
|
||||
action = False
|
||||
action = (sections[0] == 'PERMIT')
|
||||
|
||||
for entry in sections[1].split(','):
|
||||
if entry == 'ALL':
|
||||
acl.append((const.ID_MIN, _max))
|
||||
ranges = [(const.ID_MIN, _max)]
|
||||
singles = set()
|
||||
break
|
||||
|
||||
elif '-' in entry:
|
||||
start,end = entry.split('-')
|
||||
start,end = int(start), int(end)
|
||||
if (const.ID_MIN <= start <= _max) or (const.ID_MIN <= end <= _max):
|
||||
acl.append((start, end))
|
||||
ranges.append((start, end))
|
||||
else:
|
||||
sys.exit('ACL CREATION ERROR, VALUE OUT OF RANGE ({} - {})IN RANGE-BASED ENTRY: {}'.format(const.ID_MIN, _max, entry))
|
||||
else:
|
||||
id = int(entry)
|
||||
if (const.ID_MIN <= id <= _max):
|
||||
acl.append((id, id))
|
||||
singles.add(id)
|
||||
else:
|
||||
sys.exit('ACL CREATION ERROR, VALUE OUT OF RANGE ({} - {}) IN SINGLE ID ENTRY: {}'.format(const.ID_MIN, _max, entry))
|
||||
|
||||
return (action, acl)
|
||||
merged = merge_ranges(ranges)
|
||||
starts = tuple(r[0] for r in merged)
|
||||
ends = tuple(r[1] for r in merged)
|
||||
return (action, frozenset(singles), starts, ends)
|
||||
|
||||
def build_config(_config_file):
|
||||
config = configparser.ConfigParser()
|
||||
@@ -118,6 +133,12 @@ def build_config(_config_file):
|
||||
'PATH': config.get(section, 'PATH'),
|
||||
'PING_TIME': config.getint(section, 'PING_TIME'),
|
||||
'MAX_MISSED': config.getint(section, 'MAX_MISSED'),
|
||||
# Chronic connectivity: report a repeater's ping loss over the
|
||||
# last PING_LOSS_WINDOW minutes (clamped 1..60), and flag it on
|
||||
# the dashboard at/above PING_LOSS_WARN percent. Optional; sane
|
||||
# defaults keep existing configs working.
|
||||
'PING_LOSS_WINDOW': min(60, max(1, config.getint(section, 'PING_LOSS_WINDOW', fallback=5))),
|
||||
'PING_LOSS_WARN': max(0, config.getint(section, 'PING_LOSS_WARN', fallback=5)),
|
||||
'USE_ACL': config.get(section, 'USE_ACL'),
|
||||
'REG_ACL': config.get(section, 'REG_ACL'),
|
||||
'SUB_ACL': config.get(section, 'SUB_ACL'),
|
||||
@@ -130,7 +151,11 @@ def build_config(_config_file):
|
||||
'REPORT': config.getboolean(section, 'REPORT'),
|
||||
'REPORT_INTERVAL': config.getint(section, 'REPORT_INTERVAL'),
|
||||
'REPORT_PORT': config.getint(section, 'REPORT_PORT'),
|
||||
'REPORT_CLIENTS': config.get(section, 'REPORT_CLIENTS').split(',')
|
||||
'REPORT_CLIENTS': config.get(section, 'REPORT_CLIENTS').split(','),
|
||||
# Reporting transport: 'tcp' (default, dashboard may be remote)
|
||||
# or 'unix' (same-box dashboard via REPORT_SOCKET path). Optional.
|
||||
'REPORT_TRANSPORT': config.get(section, 'REPORT_TRANSPORT', fallback='tcp').strip().lower(),
|
||||
'REPORT_SOCKET': config.get(section, 'REPORT_SOCKET', fallback='').strip(),
|
||||
})
|
||||
|
||||
elif section == 'LOGGER':
|
||||
@@ -145,6 +170,7 @@ def build_config(_config_file):
|
||||
|
||||
elif section == 'ALIASES':
|
||||
CONFIG['ALIASES'].update({
|
||||
'USE_ALIASES': config.getboolean(section, 'USE_ALIASES', fallback=False),
|
||||
'TRY_DOWNLOAD': config.getboolean(section, 'TRY_DOWNLOAD'),
|
||||
'PATH': config.get(section, 'PATH'),
|
||||
'PEER_FILE': config.get(section, 'PEER_FILE'),
|
||||
@@ -156,17 +182,16 @@ def build_config(_config_file):
|
||||
})
|
||||
|
||||
elif config.getboolean(section, 'ENABLED'):
|
||||
if config.get(section, 'MODE') == 'PEER':
|
||||
if config.get(section, 'MODE') == 'OUTBOUND':
|
||||
CONFIG['SYSTEMS'].update({section: {
|
||||
'MODE': config.get(section, 'MODE'),
|
||||
'ENABLED': config.getboolean(section, 'ENABLED'),
|
||||
'LOOSE': config.getboolean(section, 'LOOSE'),
|
||||
'SOCK_ADDR': getaddrinfo(config.get(section, 'IP'), config.getint(section, 'PORT'), proto=17)[0][4],
|
||||
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
|
||||
'PORT': config.getint(section, 'PORT'),
|
||||
'MASTER_SOCKADDR': getaddrinfo(config.get(section, 'MASTER_IP'), config.getint(section, 'MASTER_PORT'), proto=17)[0][4],
|
||||
'MASTER_IP': getaddrinfo(config.get(section, 'MASTER_IP'), 0)[0][4][0],
|
||||
'MASTER_PORT': config.getint(section, 'MASTER_PORT'),
|
||||
'SERVER_SOCKADDR': getaddrinfo(config.get(section, 'SERVER_IP'), config.getint(section, 'SERVER_PORT'), proto=17)[0][4],
|
||||
'SERVER_IP': getaddrinfo(config.get(section, 'SERVER_IP'), 0)[0][4][0],
|
||||
'SERVER_PORT': config.getint(section, 'SERVER_PORT'),
|
||||
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE'), 'utf-8'),
|
||||
'CALLSIGN': bytes(config.get(section, 'CALLSIGN').ljust(8)[:8], 'utf-8'),
|
||||
'RADIO_ID': config.getint(section, 'RADIO_ID').to_bytes(4, 'big'),
|
||||
@@ -201,12 +226,12 @@ def build_config(_config_file):
|
||||
'LAST_PING_ACK_TIME': 0,
|
||||
}})
|
||||
|
||||
elif config.get(section, 'MODE') == 'MASTER':
|
||||
elif config.get(section, 'MODE') == 'SERVER':
|
||||
CONFIG['SYSTEMS'].update({section: {
|
||||
'MODE': config.get(section, 'MODE'),
|
||||
'ENABLED': config.getboolean(section, 'ENABLED'),
|
||||
'REPEAT': config.getboolean(section, 'REPEAT'),
|
||||
'MAX_PEERS': config.getint(section, 'MAX_PEERS'),
|
||||
'MAX_REPEATERS': config.getint(section, 'MAX_REPEATERS'),
|
||||
'IP': getaddrinfo(config.get(section, 'IP'), 0)[0][4][0],
|
||||
'PORT': config.getint(section, 'PORT'),
|
||||
'PASSPHRASE': bytes(config.get(section, 'PASSPHRASE'), 'utf-8'),
|
||||
@@ -217,7 +242,7 @@ def build_config(_config_file):
|
||||
'TG1_ACL': config.get(section, 'TGID_TS1_ACL'),
|
||||
'TG2_ACL': config.get(section, 'TGID_TS2_ACL')
|
||||
}})
|
||||
CONFIG['SYSTEMS'][section].update({'PEERS': {}})
|
||||
CONFIG['SYSTEMS'][section].update({'REPEATERS': {}})
|
||||
|
||||
elif config.get(section, 'MODE') == 'OPENBRIDGE':
|
||||
CONFIG['SYSTEMS'].update({section: {
|
||||
@@ -231,6 +256,7 @@ def build_config(_config_file):
|
||||
'TARGET_IP': getaddrinfo(config.get(section, 'TARGET_IP'), 0)[0][4][0],
|
||||
'TARGET_PORT': config.getint(section, 'TARGET_PORT'),
|
||||
'BOTH_SLOTS': config.getboolean(section, 'BOTH_SLOTS'),
|
||||
'PRESERVE_SOURCE_PEER': config.getboolean(section, 'PRESERVE_SOURCE_PEER', fallback=False),
|
||||
'USE_ACL': config.getboolean(section, 'USE_ACL'),
|
||||
'SUB_ACL': config.get(section, 'SUB_ACL'),
|
||||
'TG1_ACL': config.get(section, 'TGID_ACL'),
|
||||
@@ -270,11 +296,15 @@ if __name__ == '__main__':
|
||||
CONFIG = build_config(cli_args.CONFIG_FILE)
|
||||
pprint(CONFIG)
|
||||
|
||||
from bisect import bisect_right
|
||||
def acl_check(_id, _acl):
|
||||
id = int_id(_id)
|
||||
for entry in _acl[1]:
|
||||
if entry[0] <= id <= entry[1]:
|
||||
return _acl[0]
|
||||
return not _acl[0]
|
||||
action, singles, starts, ends = _acl
|
||||
if id in singles:
|
||||
return action
|
||||
i = bisect_right(starts, id) - 1
|
||||
if i >= 0 and id <= ends[i]:
|
||||
return action
|
||||
return not action
|
||||
|
||||
print(acl_check(b'\x00\x01\x37', CONFIG['GLOBAL']['TG1_ACL']))
|
||||
@@ -38,6 +38,10 @@ ID_MAX = 16776415
|
||||
|
||||
# Timers
|
||||
STREAM_TO = .360
|
||||
# Seconds of no audio after which a stream is declared ended. On RF, a gap this
|
||||
# long means sync is lost and any resumption is a new stream (late entry), so the
|
||||
# stream is over -- we time it out and emit an END.
|
||||
STREAM_TIMEOUT = 2
|
||||
|
||||
# Options from the LC - used for late entry
|
||||
LC_OPT = b'\x00\x00\x20'
|
||||
@@ -62,7 +66,6 @@ MSTC = b'MSTC'
|
||||
RPTL = b'RPTL'
|
||||
RPTPING = b'RPTPING'
|
||||
RPTCL = b'RPTCL'
|
||||
RPTL = b'RPTL'
|
||||
RPTACK = b'RPTACK'
|
||||
RPTK = b'RPTK'
|
||||
RPTC = b'RPTC'
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# HBlink3 Dashboard — Field Reference
|
||||
|
||||
What every element on the dashboard means, section by section. The page updates
|
||||
live: it receives a full snapshot when it connects, then per-call deltas over a
|
||||
WebSocket, and it ticks all timers (durations, uptimes, countdowns) in the
|
||||
browser once per second.
|
||||
|
||||
A note on **direction**, used throughout: directions are from HBlink3's point of
|
||||
view. **RX** (green) is traffic coming *into* HBlink3 from a system; **TX** (red)
|
||||
is traffic HBlink3 is sending *out* to a system. So the radio/peer that keyed up
|
||||
shows green, and everyone hearing the repeated copy shows red.
|
||||
|
||||
---
|
||||
|
||||
## Header
|
||||
|
||||
| Element | Meaning |
|
||||
|---|---|
|
||||
| Logo / title | The HBlink logo and your `REPORT_NAME` (set in `dashboard/config.py`), with the "HBlink3 Monitor" subtitle. |
|
||||
| Connection badge | The link between **this dashboard and the HBlink3 reporting feed** — *not* the health of any radio system. **connected** (green) = receiving live data; **connecting… / disconnected** (red) = the feed at `HBLINK_IP:HBLINK_PORT` is unreachable (HBlink3 down or `[REPORTS]` not enabled); **dashboard offline** = your browser lost the dashboard itself. |
|
||||
|
||||
---
|
||||
|
||||
## Last Heard
|
||||
|
||||
The most-recent unique callers, newest first, derived live from the call log
|
||||
(no separate database). Deduplicated by source radio ID. Configure with
|
||||
`LAST_HEARD` / `LAST_HEARD_COUNT`; the **Show/Hide** button collapses the table
|
||||
and its state is remembered in your browser.
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| User | The caller's callsign and radio ID (DMR ID). |
|
||||
| Type | Call type — Group Voice, Unit Voice, Group Data, or Unit Data. |
|
||||
| Source | The source repeater/peer the call entered on (callsign and ID if known). |
|
||||
| TS | The timeslot the call used. |
|
||||
| Talkgroup | The destination talkgroup (name and ID), or subscriber for a unit call. |
|
||||
| When | **Active** while the call is in progress, otherwise how long ago it was heard. |
|
||||
|
||||
---
|
||||
|
||||
## Server Systems
|
||||
|
||||
One block per HBlink3 **SERVER** instance (repeaters/hotspots connect *to* it).
|
||||
|
||||
**Block heading**
|
||||
|
||||
| Element | Meaning |
|
||||
|---|---|
|
||||
| System name | The server's name from `hblink.cfg` (e.g. `SERVER-1`). |
|
||||
| `repeat` / `isolate` pill | `repeat` if the server forwards traffic between its own repeaters (`REPEAT: True`), `isolate` if it does not. |
|
||||
| *N* peer(s) | How many repeaters/hotspots are currently logged in. |
|
||||
| Show/Hide Connections | Collapses this system's connection table. When collapsed, system-level TS1/TS2 activity pills replace it so live calls stay visible. Default from `SERVER_REPEATERS`; per-system state is remembered in your browser. |
|
||||
|
||||
**Per-peer row**
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| Repeater | The peer's callsign and radio ID (DMR ID), with its location underneath. |
|
||||
| Connection | The peer's source `IP:PORT`, and how long it has been logged in (`up …`). |
|
||||
| Freq | The peer's RX / TX frequencies in MHz, or `N/A` if it didn't report them. |
|
||||
| CC / Slots | The peer's color code and the number of timeslots it advertises. |
|
||||
| TS1 / TS2 | The live call on timeslot 1 / timeslot 2 (see **Call cell** below), or `— idle —`. |
|
||||
|
||||
---
|
||||
|
||||
## Outbound Systems
|
||||
|
||||
One row per HBlink3 **OUTBOUND** instance (HBlink3 connecting *out* to an upstream
|
||||
server, e.g. another HBlink or a BrandMeister/IPSC2 server).
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| System | The peer instance name, with its mode (`PEER`) underneath. |
|
||||
| Identity | The callsign and radio ID HBlink3 logs in with, plus its location and the upstream `master IP:PORT`. |
|
||||
| Connection | A status dot and the login state — **YES** (green) when fully connected, otherwise the current handshake state (red). `up …` is time since connect. |
|
||||
| Pings S/A/L | Keepalive pings **S**ent / **A**cknowledged / **L**ost (sent − acked). Growing "Lost" means the link to the master is unhealthy. |
|
||||
| TS1 / TS2 | The live call on each timeslot (see **Call cell**), or `— idle —`. |
|
||||
|
||||
---
|
||||
|
||||
## OpenBridge Systems
|
||||
|
||||
One row per **OPENBRIDGE** instance. OpenBridge is a trunk between two networks
|
||||
and is **not timeslot-bound** — it carries any number of concurrent streams at
|
||||
once, so this section lists them side by side rather than in TS1/TS2 columns.
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| System | The OpenBridge instance name. |
|
||||
| Network / Target | The local `NETWORK_ID`, and the far end's `TARGET_IP:TARGET_PORT`. |
|
||||
| Active Streams | A chip per concurrent call: direction (**RX** inbound / **TX** outbound), source, and destination. `— idle —` when none are active. Seeing several chips here at once is normal and expected for OpenBridge. |
|
||||
|
||||
---
|
||||
|
||||
## Call cell
|
||||
|
||||
The colored box shown in the TS1/TS2 columns (and, as chips, under OpenBridge).
|
||||
|
||||
| Part | Meaning |
|
||||
|---|---|
|
||||
| Top line | Call type (**Group** or **Unit**) · source · destination, using callsigns / talkgroup names where known. For a group call the destination is a talkgroup; for a unit (private) call it's another subscriber. |
|
||||
| Bottom line | The source's raw radio ID · elapsed call time (ticking live). |
|
||||
| Color | **Green** = RX (this system/peer is the one transmitting in). **Red** = TX (the call is going out to this system — i.e. it's hearing repeated traffic). |
|
||||
| `— idle —` | No active call on that slot. |
|
||||
|
||||
A call that ends normally clears immediately. If a terminator is lost, the
|
||||
dashboard drops the stale call after ~30 s so it can't linger forever (HBlink3
|
||||
itself also times streams out at ~2 s and emits the end — this is just a backstop
|
||||
for a non-local dashboard or a feed interruption).
|
||||
|
||||
---
|
||||
|
||||
## Conference Bridges
|
||||
|
||||
One table per conference bridge defined in `rules.py`, with a row per member
|
||||
system. (Empty if you're running `bridge_all` or standalone `hblink.py`, which
|
||||
have no conference-bridge rules.)
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| System | The member system's name. |
|
||||
| TS | The timeslot this rule matches on. |
|
||||
| TGID | The talkgroup this rule matches, with its name if known. |
|
||||
| Status | **Connected** (green) = this member is currently bridged (`ACTIVE`); **Disconnected** (red) = traffic is not flowing for this member. |
|
||||
| Timeout | For timed rules (`TO_TYPE` ON/OFF), the live countdown until the state flips; `N/A` for rules without a timer. |
|
||||
| Action | What the timeout will do: **Disconnect** (an `ON` timer), **Connect** (an `OFF` timer), or **None**. |
|
||||
| Connect TGIDs | Talkgroup IDs that, when keyed, turn this member **on** (the rule's `ON` list). |
|
||||
| Disconnect TGIDs | Talkgroup IDs that turn this member **off** (the rule's `OFF` list). |
|
||||
|
||||
---
|
||||
|
||||
## Call Log
|
||||
|
||||
A reverse-chronological feed of call activity. Only **ingress (RX)** legs are
|
||||
logged — one line when a call starts and one when it ends — matching the classic
|
||||
HBmonitor behavior, so each conversation appears once regardless of how many
|
||||
systems it was bridged to.
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| Timestamp | Local time the event was received. |
|
||||
| Event | Call type plus **START** (blue) or **END** (green). |
|
||||
| System | The system the call came in on. |
|
||||
| Source → Dest | Source subscriber (callsign/ID), the timeslot (`TS1`/`TS2`), and the destination talkgroup or subscriber (name/ID). |
|
||||
| Duration | On an **END** line, how long the call lasted. |
|
||||
|
||||
---
|
||||
|
||||
## How the data arrives (and update latency)
|
||||
|
||||
- **Calls** are event-driven and appear **instantly** as START/END events.
|
||||
- **System and peer state** (a peer connecting or dropping, ping counts, bridge
|
||||
on/off state) comes from a periodic snapshot every `REPORT_INTERVAL` seconds
|
||||
(`[REPORTS]` in `hblink.cfg`; ~10 s is a good value for dashboard use). So a
|
||||
newly connected peer can take up to one interval to appear.
|
||||
- All elapsed/uptime/countdown timers advance in the browser; the server sends
|
||||
time *deltas*, so the dashboard needs no clock synchronization with the server.
|
||||
@@ -0,0 +1,89 @@
|
||||
# HBlink3 Dashboard
|
||||
|
||||
A real-time web dashboard for HBlink3, in the spirit of the old HBmonitor (same
|
||||
tables and information) but with a modern dark UI and a far more efficient data
|
||||
path. It is a **separate program** from the HBlink3 core.
|
||||
|
||||
It connects to HBlink3's reporting feed, keeps the display state, and pushes
|
||||
live **JSON deltas** to the browser over a WebSocket. The browser renders the
|
||||
tables and ticks the duration/uptime counters itself — no full-page or
|
||||
full-table refreshes, and no pickle on the wire.
|
||||
|
||||
## What it shows
|
||||
|
||||
- **Last Heard** — the most-recent unique callers (user, call type, source
|
||||
repeater, timeslot, talkgroup, and when), derived live from the call log. The
|
||||
table is collapsible; its open/closed state is remembered in your browser.
|
||||
- **Server Systems** — each server and its connected repeaters (ID, callsign,
|
||||
location, IP, uptime, frequency, colorcode, slots) with the live call on each
|
||||
timeslot (source → destination, color-coded by direction). Each server's
|
||||
connection list can be collapsed with the **Show/Hide Connections** button;
|
||||
when collapsed, system-level TS1/TS2 activity pills replace the table so you
|
||||
still see what's talking. State is remembered per-browser.
|
||||
- **Outbound Systems** — outbound connections with health (connection state,
|
||||
pings sent/ackd/lost) and live per-timeslot calls.
|
||||
- **OpenBridge Systems** — network ID, target, and active streams.
|
||||
- **Conference Bridges** — each bridge's members: timeslot, talkgroup,
|
||||
connected/disconnected state, timeout countdown, and on/off trigger talkgroups.
|
||||
- **Call Log** — a running log of call starts and ends.
|
||||
|
||||
For a field-by-field explanation of everything on the screen, see
|
||||
[FIELDS.md](FIELDS.md).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
python3 -m venv venv && source venv/bin/activate # or reuse the HBlink3 venv
|
||||
pip install -r requirements.txt
|
||||
cp config_sample.py config.py # then edit config.py
|
||||
python server.py # or, from the repo root: python run_dashboard.py
|
||||
```
|
||||
|
||||
Open `http://<host>:8080`.
|
||||
|
||||
## Configuration (`config.py`)
|
||||
|
||||
- `HBLINK_IP` / `HBLINK_PORT` — HBlink3's reporting feed (the `[REPORTS]` section
|
||||
of `hblink.cfg`: `REPORT_PORT`, default 4321). Used when `HBLINK_TRANSPORT` is `tcp`.
|
||||
- `HBLINK_TRANSPORT` / `HBLINK_SOCKET` — optional. Set `HBLINK_TRANSPORT = 'unix'`
|
||||
and `HBLINK_SOCKET` to the daemon's `REPORT_SOCKET` path to connect over a local
|
||||
Unix socket instead of TCP (same-box dashboard; immune to NIC flaps / conntrack
|
||||
eviction). Defaults to `tcp`. Must match the daemon's `REPORT_TRANSPORT`.
|
||||
- `WEB_HOST` / `WEB_PORT` — where the dashboard listens.
|
||||
- `PATH` / `*_FILE` — alias files mapping DMR IDs to callsigns/talkgroup names
|
||||
(the same files HBlink3 uses; point `PATH` at HBlink3's directory to share them).
|
||||
- `REPORT_NAME`, `LOG_LINES` — branding and call-log length.
|
||||
- `LAST_HEARD` — default state of the Last Heard table: `'open'`, `'closed'`, or
|
||||
`'off'` (omit it entirely). Users can still toggle it in their own browser.
|
||||
- `LAST_HEARD_COUNT` — number of recent callers to list (default 10).
|
||||
- `SERVER_REPEATERS` — default state of each server system's connection list:
|
||||
`'open'` or `'closed'`. Use `'closed'` for a compact view on many-repeater
|
||||
servers; per-system browser choices override it.
|
||||
|
||||
## HBlink3 side
|
||||
|
||||
In `hblink.cfg`, under `[REPORTS]`:
|
||||
|
||||
- `REPORT: True`
|
||||
- `REPORT_PORT` must match the dashboard's `HBLINK_PORT`
|
||||
- `REPORT_CLIENTS` must include the dashboard host's IP (or `*`)
|
||||
- `REPORT_INTERVAL` sets how often the full systems/bridge snapshot is resent as a
|
||||
slow resync. **Live calls and repeater connect/disconnect are pushed as they
|
||||
happen** (granular events); the periodic snapshot is just a safety-net resync and
|
||||
also carries the per-repeater ping-quality figures, so a larger value like `60`
|
||||
is fine.
|
||||
|
||||
## Notes
|
||||
|
||||
- This replaces the old **HBmonitor**. HBlink3's reporting feed is now
|
||||
newline-delimited JSON, which HBmonitor does not understand.
|
||||
- The dashboard depends on FastAPI + Uvicorn; the HBlink3 core itself remains
|
||||
stdlib-only.
|
||||
- **Demo mode** — append `?demo` to the dashboard URL (e.g.
|
||||
`http://localhost:8080/?demo`) to load a static pre-populated scenario showing
|
||||
all visual elements: a SERVER with three repeaters, an OUTBOUND peer, an
|
||||
OpenBridge connection, active calls bridged coherently across systems, a slot
|
||||
in group-hangtime, and a populated call log. No HBlink3 connection is required.
|
||||
This is useful for understanding what the dashboard looks like under normal
|
||||
operating conditions before any traffic has been seen.
|
||||
@@ -0,0 +1,61 @@
|
||||
###############################################################################
|
||||
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
###############################################################################
|
||||
|
||||
# Copy this file to config.py and edit for your install.
|
||||
|
||||
# Display / branding
|
||||
REPORT_NAME = 'My HBlink3 System' # Shown in the dashboard header
|
||||
LOGO_FILE = '' # Path to a logo image (png/jpg/svg/gif); '' = no logo
|
||||
|
||||
# Connection to HBlink3's reporting feed (the [REPORTS] section of hblink.cfg)
|
||||
HBLINK_IP = '127.0.0.1' # HBlink3 reporting host
|
||||
HBLINK_PORT = 4321 # HBlink3 reporting TCP port (REPORT_PORT)
|
||||
|
||||
# Web server
|
||||
WEB_HOST = '0.0.0.0' # Interface to bind the dashboard web server
|
||||
WEB_PORT = 8080 # Port (must be > 1024 if not running as root)
|
||||
|
||||
# Call log
|
||||
LOG_LINES = 300 # Number of recent call-log entries to retain
|
||||
|
||||
# Last Heard table — most-recent unique callers, shown at the top of the dashboard.
|
||||
LAST_HEARD = 'open' # 'open' | 'closed' | 'off' (default state; 'off' hides it entirely)
|
||||
LAST_HEARD_COUNT = 10 # Number of recent callers to list
|
||||
|
||||
# Server systems — default state for each server system's collapsible repeater list.
|
||||
# Users can toggle each system open/closed in their own browser (remembered locally).
|
||||
# When collapsed, system-level TS1/TS2 activity pills replace the repeater table.
|
||||
SERVER_REPEATERS = 'open' # 'open' | 'closed' ('closed' gives a compact view for many-repeater servers)
|
||||
|
||||
# Alias files — the dashboard owns downloading, storing, and refreshing these.
|
||||
# Files live in the dashboard's own subdirectory (PATH = './') by default.
|
||||
# Set TRY_DOWNLOAD = True to fetch fresh files from radioid.net on startup and
|
||||
# refresh them daily. Set False to manage files manually or skip alias lookups.
|
||||
#
|
||||
# NOTE: HBlink3/bridge.py can also load these files for callsign display in
|
||||
# logs, but doing so costs RAM — roughly 150 MB with the country filter below,
|
||||
# ~500 MB unfiltered, and that again in the dashboard process if it loads them
|
||||
# too — so it is STRONGLY DISCOURAGED. If you want log callsigns, point
|
||||
# bridge.py's [ALIASES] PATH at this directory and keep TRY_DOWNLOAD: False
|
||||
# there — the dashboard handles the downloads.
|
||||
TRY_DOWNLOAD = True
|
||||
PATH = './' # MUST END IN '/' — dashboard's own directory
|
||||
PEER_FILE = 'peer_ids.json'
|
||||
SUBSCRIBER_FILE = 'subscriber_ids.json'
|
||||
TGID_FILE = 'talkgroup_ids.json' # optional {id: name}; ok if missing
|
||||
LOCAL_SUB_FILE = '' # optional local subscriber override, '' to disable
|
||||
LOCAL_PEER_FILE = '' # optional local peer override, '' to disable
|
||||
PEER_URL = 'https://www.radioid.net/static/rptrs.json'
|
||||
SUBSCRIBER_URL = 'https://www.radioid.net/static/users.json'
|
||||
STALE_DAYS = 7 # re-download after this many days
|
||||
|
||||
# Country filter for subscriber downloads. Only records matching these country
|
||||
# names (as they appear in the radioid.net data) are written to disk, which
|
||||
# keeps memory use and load time low. Set to [] or None to load all countries.
|
||||
FILTER_COUNTRIES = ['United States', 'France']
|
||||
@@ -0,0 +1,5 @@
|
||||
# HBlink3 dashboard dependencies (a separate program from the core).
|
||||
# The dashboard uses FastAPI + Uvicorn; the HBlink3 core itself stays stdlib-only.
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
dmr_utils3>=0.1.30 # DMR ID -> callsign / talkgroup-name resolution
|
||||
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
###############################################################################
|
||||
# Copyright (C) 2016-2026 Cortney T. Buffington, N0MJS <n0mjs@me.com>
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
###############################################################################
|
||||
|
||||
'''
|
||||
HBlink3 dashboard backend.
|
||||
|
||||
Connects to HBlink3's NDJSON reporting feed (one JSON object per line), keeps the
|
||||
authoritative display state, and serves a single-page UI that receives live JSON
|
||||
deltas over a WebSocket. Run: python server.py (or via run_dashboard.py).
|
||||
'''
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
from urllib.request import urlopen
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
import ijson
|
||||
import uvicorn
|
||||
|
||||
from dmr_utils3.utils import mk_id_dict, get_alias
|
||||
|
||||
# Ensure THIS directory's config.py wins over HBlink3's top-level config.py.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC = os.path.join(HERE, 'static')
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
# ---- configuration -----------------------------------------------------------
|
||||
try:
|
||||
from config import (REPORT_NAME, HBLINK_IP, HBLINK_PORT, WEB_HOST, WEB_PORT,
|
||||
LOG_LINES, PATH, PEER_FILE, SUBSCRIBER_FILE, TGID_FILE,
|
||||
LOCAL_SUB_FILE, LOCAL_PEER_FILE)
|
||||
except ImportError:
|
||||
sys.exit('No config.py found -- copy config_sample.py to config.py and edit it.')
|
||||
|
||||
try:
|
||||
from config import TRY_DOWNLOAD, PEER_URL, SUBSCRIBER_URL, STALE_DAYS
|
||||
except ImportError:
|
||||
TRY_DOWNLOAD = False
|
||||
PEER_URL = 'https://www.radioid.net/static/rptrs.json'
|
||||
SUBSCRIBER_URL = 'https://www.radioid.net/static/users.json'
|
||||
STALE_DAYS = 7
|
||||
|
||||
try:
|
||||
from config import FILTER_COUNTRIES
|
||||
except ImportError:
|
||||
FILTER_COUNTRIES = None
|
||||
|
||||
try:
|
||||
from config import LAST_HEARD, LAST_HEARD_COUNT
|
||||
except ImportError:
|
||||
LAST_HEARD = 'open'
|
||||
LAST_HEARD_COUNT = 10
|
||||
|
||||
try:
|
||||
from config import SERVER_REPEATERS
|
||||
except ImportError:
|
||||
SERVER_REPEATERS = 'open'
|
||||
|
||||
# Feed transport: 'tcp' (default, connect to HBLINK_IP:HBLINK_PORT) or 'unix'
|
||||
# (connect to the daemon's local Unix socket HBLINK_SOCKET). Optional in config.
|
||||
try:
|
||||
from config import HBLINK_TRANSPORT
|
||||
except ImportError:
|
||||
HBLINK_TRANSPORT = 'tcp'
|
||||
try:
|
||||
from config import HBLINK_SOCKET
|
||||
except ImportError:
|
||||
HBLINK_SOCKET = ''
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
|
||||
logger = logging.getLogger('hbdash')
|
||||
|
||||
# Drop an "active" stream this many seconds after its START if no END arrives.
|
||||
# This is only a backstop for a lost/never-sent terminator while the feed stays
|
||||
# connected -- a dropped feed already clears all active streams on disconnect,
|
||||
# and HBlink3 itself synthesizes an END a couple seconds after audio stops
|
||||
# (bridge.py STREAM_TIMEOUT). Keep it well above any real transmission (amateur
|
||||
# TOT is ~3 min) so legitimate long calls are never clipped mid-stream.
|
||||
STREAM_STALE = 300
|
||||
|
||||
|
||||
# ---- alias resolution --------------------------------------------------------
|
||||
def _abs(p):
|
||||
return p if os.path.isabs(p) else os.path.join(HERE, p)
|
||||
|
||||
def _stream_id_file(url, path, json_key, countries, stale_secs):
|
||||
now = time.time()
|
||||
if os.path.isfile(path) and (os.path.getmtime(path) + stale_secs) >= now:
|
||||
logger.info('ID ALIAS MAPPER: %s is current, not downloaded', os.path.basename(path))
|
||||
return
|
||||
no_verify = ssl._create_unverified_context()
|
||||
tmp = path + '.tmp'
|
||||
try:
|
||||
with urlopen(url, context=no_verify) as response, \
|
||||
open(tmp, 'w', encoding='utf-8') as out:
|
||||
out.write('{"%s":[' % json_key)
|
||||
first = True
|
||||
for record in ijson.items(response, json_key + '.item'):
|
||||
if not countries or record.get('country') in countries:
|
||||
if not first:
|
||||
out.write(',')
|
||||
json.dump({'id': record['id'], 'callsign': record['callsign']}, out)
|
||||
first = False
|
||||
out.write(']}')
|
||||
os.replace(tmp, path)
|
||||
label = ', '.join(sorted(countries)) if countries else 'all countries'
|
||||
logger.info('ID ALIAS MAPPER: %s downloaded (%s)', os.path.basename(path), label)
|
||||
except IOError as e:
|
||||
logger.error('ID ALIAS MAPPER: download of %s failed: %s', os.path.basename(path), e)
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
|
||||
def _download_aliases():
|
||||
if not TRY_DOWNLOAD:
|
||||
return
|
||||
base = _abs(PATH)
|
||||
stale_secs = int(STALE_DAYS) * 86400
|
||||
countries = set(FILTER_COUNTRIES) if FILTER_COUNTRIES else None
|
||||
_stream_id_file(PEER_URL, base + PEER_FILE, 'rptrs', countries, stale_secs)
|
||||
_stream_id_file(SUBSCRIBER_URL, base + SUBSCRIBER_FILE, 'users', countries, stale_secs)
|
||||
|
||||
def _reload_aliases():
|
||||
global PEER_IDS, SUBSCRIBER_IDS, TALKGROUP_IDS
|
||||
base = _abs(PATH)
|
||||
PEER_IDS = mk_id_dict(base, PEER_FILE)
|
||||
SUBSCRIBER_IDS = mk_id_dict(base, SUBSCRIBER_FILE)
|
||||
TALKGROUP_IDS = mk_id_dict(base, TGID_FILE)
|
||||
if LOCAL_PEER_FILE:
|
||||
PEER_IDS.update(mk_id_dict(base, LOCAL_PEER_FILE))
|
||||
if LOCAL_SUB_FILE:
|
||||
SUBSCRIBER_IDS.update(mk_id_dict(base, LOCAL_SUB_FILE))
|
||||
logger.info('aliases loaded: %d peers, %d subscribers, %d talkgroups',
|
||||
len(PEER_IDS), len(SUBSCRIBER_IDS), len(TALKGROUP_IDS))
|
||||
|
||||
PEER_IDS = {}
|
||||
SUBSCRIBER_IDS = {}
|
||||
TALKGROUP_IDS = {}
|
||||
|
||||
async def _alias_refresh_loop():
|
||||
while True:
|
||||
await asyncio.sleep(86400)
|
||||
logger.info('aliases: starting daily refresh')
|
||||
await asyncio.to_thread(_download_aliases)
|
||||
_reload_aliases()
|
||||
|
||||
# Logo
|
||||
try:
|
||||
from config import LOGO_FILE
|
||||
except ImportError:
|
||||
LOGO_FILE = ''
|
||||
_logo_path = _abs(LOGO_FILE) if LOGO_FILE else ''
|
||||
_logo_exists = bool(_logo_path and os.path.isfile(_logo_path))
|
||||
_logo_media_type = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
||||
}.get(os.path.splitext(_logo_path)[1].lower(), 'image/png') if _logo_exists else 'image/png'
|
||||
LOGO_HTML = '<img src="/logo" alt="" class="logo">' if _logo_exists else ''
|
||||
|
||||
def alias(_id, _dict):
|
||||
a = get_alias(_id, _dict)
|
||||
return None if a == _id else a
|
||||
|
||||
|
||||
# ---- shared state ------------------------------------------------------------
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.systems = {} # last 'config' event payload
|
||||
self.bridges = {} # last 'bridges' event payload (enriched)
|
||||
self.streams = {} # key -> last START stream event (active calls)
|
||||
self.log = deque(maxlen=LOG_LINES)
|
||||
self.hblink = False
|
||||
self.clients = set() # connected dashboard WebSockets
|
||||
self.ping_time = 5 # PING_TIME from hblink global config
|
||||
self.max_missed = 3 # MAX_MISSED from hblink global config
|
||||
self.ping_loss_warn = 5 # PING_LOSS_WARN %: gold callsign at/above this
|
||||
|
||||
STATE = State()
|
||||
|
||||
|
||||
def stream_key(evt):
|
||||
return '{}|{}|{}'.format(evt['system'], evt['slot'], evt['stream_id'])
|
||||
|
||||
def enrich_stream(evt):
|
||||
evt['src_alias'] = alias(evt['src'], SUBSCRIBER_IDS)
|
||||
evt['peer_alias'] = alias(evt['peer'], PEER_IDS)
|
||||
# A unit call's destination is a subscriber; a group call's is a talkgroup.
|
||||
if evt.get('call_type') == 'UNIT VOICE':
|
||||
evt['dst_alias'] = alias(evt['dst'], SUBSCRIBER_IDS)
|
||||
else:
|
||||
evt['dst_alias'] = alias(evt['dst'], TALKGROUP_IDS)
|
||||
return evt
|
||||
|
||||
# Convert server-side absolute epochs to "seconds since/until" deltas so the
|
||||
# browser can tick live counters without depending on the client's clock.
|
||||
def enrich_config(systems):
|
||||
now = time.time()
|
||||
for sysview in systems.values():
|
||||
if sysview['MODE'] == 'SERVER':
|
||||
for p in sysview.get('REPEATERS', {}).values():
|
||||
p['connected_secs'] = int(max(0, now - p.get('CONNECTED', now)))
|
||||
last_ping = p.get('LAST_PING', 0)
|
||||
p['last_ping_secs'] = int(max(0, now - last_ping)) if last_ping else None
|
||||
elif sysview['MODE'] == 'OUTBOUND':
|
||||
c = sysview.get('STATS', {}).get('CONNECTED')
|
||||
sysview['STATS']['connected_secs'] = int(max(0, now - c)) if c else None
|
||||
return systems
|
||||
|
||||
def enrich_bridges(bridges):
|
||||
now = time.time()
|
||||
for members in bridges.values():
|
||||
for m in members:
|
||||
m['TGID_NAME'] = alias(m['TGID'], TALKGROUP_IDS)
|
||||
if m['TO_TYPE'] in ('ON', 'OFF'):
|
||||
m['remaining'] = int(m['TIMER'] - now)
|
||||
else:
|
||||
m['remaining'] = None
|
||||
return bridges
|
||||
|
||||
|
||||
async def broadcast(obj):
|
||||
dead = set()
|
||||
for ws in STATE.clients:
|
||||
try:
|
||||
await ws.send_json(obj)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
STATE.clients -= dead
|
||||
|
||||
|
||||
async def handle_event(evt):
|
||||
global FEED_READ_TIMEOUT
|
||||
t = evt.get('type')
|
||||
if t == 'config':
|
||||
interval = evt.get('report_interval')
|
||||
if interval:
|
||||
FEED_READ_TIMEOUT = max(float(interval) * 3, 30.0)
|
||||
STATE.ping_time = evt.get('ping_time', STATE.ping_time)
|
||||
STATE.max_missed = evt.get('max_missed', STATE.max_missed)
|
||||
STATE.ping_loss_warn = evt.get('ping_loss_warn', STATE.ping_loss_warn)
|
||||
STATE.systems = enrich_config(evt['systems'])
|
||||
await broadcast({'type': 'config', 'systems': STATE.systems,
|
||||
'ping_time': STATE.ping_time, 'max_missed': STATE.max_missed,
|
||||
'ping_loss_warn': STATE.ping_loss_warn})
|
||||
elif t in ('repeater_connected', 'repeater_disconnected'):
|
||||
# Granular repeater connect/disconnect delta. Apply it to the in-memory
|
||||
# systems view and re-broadcast the (enriched) config so the browser
|
||||
# renders it without waiting for the next full push. Ignored if the
|
||||
# system isn't known yet -- the on-connect snapshot will carry it.
|
||||
sysview = STATE.systems.get(evt.get('system'))
|
||||
if sysview is not None and sysview.get('MODE') == 'SERVER':
|
||||
reps = sysview.setdefault('REPEATERS', {})
|
||||
rid = str(evt.get('radio_id'))
|
||||
if t == 'repeater_connected' and evt.get('info') is not None:
|
||||
reps[rid] = evt['info']
|
||||
elif t == 'repeater_disconnected':
|
||||
reps.pop(rid, None)
|
||||
STATE.systems = enrich_config(STATE.systems)
|
||||
await broadcast({'type': 'config', 'systems': STATE.systems,
|
||||
'ping_time': STATE.ping_time, 'max_missed': STATE.max_missed,
|
||||
'ping_loss_warn': STATE.ping_loss_warn})
|
||||
elif t == 'bridges':
|
||||
STATE.bridges = enrich_bridges(evt['bridges'])
|
||||
await broadcast({'type': 'bridges', 'bridges': STATE.bridges})
|
||||
elif t in ('stream_start', 'stream_end'):
|
||||
enrich_stream(evt)
|
||||
evt['_ts'] = time.time() # authoritative server time so "Last Heard" age is correct across reloads
|
||||
key = stream_key(evt)
|
||||
if evt['action'] == 'START':
|
||||
evt['_seen'] = time.time()
|
||||
STATE.streams[key] = evt
|
||||
else:
|
||||
STATE.streams.pop(key, None)
|
||||
# Log ingress (RX) legs only, matching the original monitor's behavior.
|
||||
if evt['trx'] == 'RX':
|
||||
STATE.log.appendleft(evt)
|
||||
await broadcast(evt)
|
||||
elif t == 'ping':
|
||||
pass # liveness heartbeat; receiving it already reset the feed read timeout
|
||||
else:
|
||||
logger.debug('ignoring unknown event type: %s', t)
|
||||
|
||||
|
||||
# hblink3 pushes a config snapshot every REPORT_INTERVAL seconds -- that push is
|
||||
# the de-facto heartbeat. If we go long enough with no line at all, the link is
|
||||
# dead (whether or not a clean FIN arrived); without this, a silently-severed
|
||||
# connection leaves readline() blocked forever and the reconnect loop never runs.
|
||||
# The timeout must be safely LARGER than the push interval or it false-trips on a
|
||||
# healthy idle link -- so we size it to 3x the interval the daemon advertises (see
|
||||
# handle_event), not a fixed value. TCP keepalive (above) stays the primary
|
||||
# detector of a truly dead socket. The default applies only until the first
|
||||
# config arrives (which the daemon sends immediately on connect).
|
||||
FEED_READ_TIMEOUT = 180.0
|
||||
|
||||
|
||||
def _enable_tcp_keepalive(writer):
|
||||
sock = writer.get_extra_info('socket')
|
||||
if sock is None:
|
||||
return
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
if hasattr(socket, 'TCP_KEEPIDLE'):
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
|
||||
except OSError as e:
|
||||
logger.warning('could not set TCP keepalive on HBlink3 feed: %s', e)
|
||||
|
||||
|
||||
# ---- HBlink3 feed client (with reconnect) ------------------------------------
|
||||
async def hblink_feed():
|
||||
while True:
|
||||
writer = None
|
||||
try:
|
||||
if HBLINK_TRANSPORT == 'unix':
|
||||
reader, writer = await asyncio.open_unix_connection(HBLINK_SOCKET)
|
||||
logger.info('connected to HBlink3 feed at unix socket %s', HBLINK_SOCKET)
|
||||
else:
|
||||
reader, writer = await asyncio.open_connection(HBLINK_IP, HBLINK_PORT)
|
||||
_enable_tcp_keepalive(writer)
|
||||
logger.info('connected to HBlink3 feed at %s:%s', HBLINK_IP, HBLINK_PORT)
|
||||
STATE.hblink = True
|
||||
await broadcast({'type': 'hblink', 'connected': True})
|
||||
while True:
|
||||
try:
|
||||
line = await asyncio.wait_for(reader.readline(), FEED_READ_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning('no data from HBlink3 for %ss; assuming link dead, reconnecting',
|
||||
FEED_READ_TIMEOUT)
|
||||
break
|
||||
if not line:
|
||||
break # connection closed
|
||||
try:
|
||||
await handle_event(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('bad JSON line from HBlink3: %r', line[:120])
|
||||
except (ConnectionRefusedError, OSError) as e:
|
||||
logger.warning('HBlink3 feed unavailable (%s); retrying', e)
|
||||
finally:
|
||||
if writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
if STATE.hblink:
|
||||
STATE.hblink = False
|
||||
STATE.systems = {}
|
||||
STATE.bridges = {}
|
||||
STATE.streams = {}
|
||||
await broadcast({'type': 'hblink', 'connected': False})
|
||||
await asyncio.sleep(3) # reconnect delay
|
||||
|
||||
|
||||
async def reap_streams():
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
now = time.time()
|
||||
stale = [k for k, e in STATE.streams.items() if now - e.get('_seen', now) > STREAM_STALE]
|
||||
for k in stale:
|
||||
STATE.streams.pop(k, None)
|
||||
if stale:
|
||||
logger.debug('reaped %d stale stream(s)', len(stale))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
await asyncio.to_thread(_download_aliases)
|
||||
_reload_aliases()
|
||||
refresher = asyncio.create_task(_alias_refresh_loop())
|
||||
feed = asyncio.create_task(hblink_feed())
|
||||
reaper = asyncio.create_task(reap_streams())
|
||||
yield
|
||||
feed.cancel()
|
||||
reaper.cancel()
|
||||
refresher.cancel()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
# ---- HTTP + WebSocket --------------------------------------------------------
|
||||
@app.get('/', response_class=HTMLResponse)
|
||||
async def index():
|
||||
with open(os.path.join(STATIC, 'dashboard.html'), encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
return (html.replace('{{REPORT_NAME}}', REPORT_NAME)
|
||||
.replace('{{LOGO_HTML}}', LOGO_HTML)
|
||||
.replace('{{LAST_HEARD}}', str(LAST_HEARD))
|
||||
.replace('{{LAST_HEARD_COUNT}}', str(LAST_HEARD_COUNT))
|
||||
.replace('{{SERVER_REPEATERS}}', str(SERVER_REPEATERS)))
|
||||
|
||||
@app.get('/logo')
|
||||
async def serve_logo():
|
||||
if not _logo_exists:
|
||||
raise HTTPException(status_code=404)
|
||||
return FileResponse(_logo_path, media_type=_logo_media_type)
|
||||
|
||||
@app.get('/api/state')
|
||||
async def api_state():
|
||||
return JSONResponse(current_state())
|
||||
|
||||
def current_state():
|
||||
return {
|
||||
'report_name': REPORT_NAME,
|
||||
'hblink': STATE.hblink,
|
||||
'systems': STATE.systems,
|
||||
'bridges': STATE.bridges,
|
||||
'streams': list(STATE.streams.values()),
|
||||
'log': list(STATE.log),
|
||||
'ping_time': STATE.ping_time,
|
||||
'max_missed': STATE.max_missed,
|
||||
'ping_loss_warn': STATE.ping_loss_warn,
|
||||
}
|
||||
|
||||
@app.websocket('/ws')
|
||||
async def ws_endpoint(ws: WebSocket):
|
||||
await ws.accept()
|
||||
STATE.clients.add(ws)
|
||||
try:
|
||||
await ws.send_json({'type': 'initial', **current_state()})
|
||||
while True:
|
||||
await ws.receive_text() # ignore; keep the socket open
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
STATE.clients.discard(ws)
|
||||
|
||||
|
||||
def main():
|
||||
uvicorn.run(app, host=WEB_HOST, port=WEB_PORT, log_level='warning')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,486 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{REPORT_NAME}} — HBlink3</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f172a; --panel:#1e293b; --panel2:#172033; --border:#334155;
|
||||
--text:#e2e8f0; --muted:#94a3b8; --accent:#38bdf8;
|
||||
--green:#16a34a; --green-bg:#16351f; --red:#dc2626; --red-bg:#3a1620;
|
||||
--orange:#ea580c; --orange-bg:#3a2412; --warn:#fbbf24;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text);padding:14px;font-size:13px}
|
||||
.wrap{max-width:1600px;margin:0 auto}
|
||||
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:10px}
|
||||
.brand{display:flex;align-items:center;gap:14px}
|
||||
.brand .logo{height:46px;width:auto;display:block}
|
||||
h1{color:var(--accent);font-size:24px;font-weight:600}
|
||||
h1 small{color:var(--muted);font-size:14px;font-weight:400;margin-left:8px}
|
||||
.badge{padding:6px 14px;border-radius:6px;font-weight:600;font-size:13px;border:1px solid var(--border)}
|
||||
.badge.up{background:var(--green-bg);color:#4ade80;border-color:var(--green)}
|
||||
.badge.down{background:var(--red-bg);color:#f87171;border-color:var(--red)}
|
||||
section{margin-bottom:22px}
|
||||
h2{font-size:15px;color:var(--accent);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px}
|
||||
h3{font-size:13px;color:var(--muted);margin:12px 0 6px}
|
||||
table{width:100%;border-collapse:collapse;background:var(--panel);border-radius:8px;overflow:hidden;margin-bottom:12px}
|
||||
th{background:var(--panel2);color:var(--muted);text-align:left;padding:8px 10px;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border)}
|
||||
td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.sub{color:var(--muted);font-size:11px}
|
||||
.mono{font-variant-numeric:tabular-nums}
|
||||
.sysname{font-weight:600;color:var(--accent)}
|
||||
.bridge-head td{background:var(--panel2);color:#fff;font-weight:600;font-size:12px;letter-spacing:.02em;padding-top:9px;padding-bottom:9px}
|
||||
.pill{display:inline-block;padding:2px 7px;border-radius:10px;font-size:10px;border:1px solid var(--border);color:var(--muted)}
|
||||
.call{border-radius:5px;padding:5px 8px;min-height:30px;width:100%;box-sizing:border-box;overflow:hidden}
|
||||
.call.idle{color:#475569;background:var(--panel2);border:1px solid transparent}
|
||||
.call.rx{background:var(--green-bg);color:#86efac;border:1px solid var(--green)} /* receive */
|
||||
.call.tx{background:var(--red-bg);color:#fca5a5;border:1px solid var(--red)} /* transmit */
|
||||
.call.hang{background:#0d2137;color:#7dd3fc;border:1px solid #075985}
|
||||
.call.rx .sub,.call.tx .sub,.call.hang .sub{color:inherit;opacity:.85}
|
||||
.call .call-top{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.call b{color:var(--text)}
|
||||
.status-ok{color:#4ade80;font-weight:600}
|
||||
.status-bad{color:#f87171;font-weight:600}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
||||
.dot.ok{background:#22c55e}.dot.bad{background:#ef4444}
|
||||
.empty{color:var(--muted);padding:10px;background:var(--panel);border-radius:8px;text-align:center}
|
||||
#log{background:#0b1220;border:1px solid var(--border);border-radius:8px;height:230px;overflow-y:auto;padding:8px 10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5}
|
||||
#log .start{color:#7dd3fc}#log .end{color:#86efac}#log .ts{color:#64748b}
|
||||
.slotcol{width:255px}
|
||||
.sys-row{display:flex;justify-content:space-between;align-items:center;margin:12px 0 4px}
|
||||
.sys-row h3{margin:0}
|
||||
.ts-hdrs{display:flex;flex-shrink:0}
|
||||
.ts-hdr{width:255px;padding-left:10px;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;box-sizing:border-box}
|
||||
.sys-block .sys-pills{display:none} /* system pills hidden while expanded */
|
||||
.sys-block.collapsed .sys-pills{display:flex;justify-content:flex-end;margin:0 0 12px}
|
||||
.sys-block.collapsed table{display:none} /* hide repeater table when collapsed */
|
||||
.sys-pills .slotcol{padding-left:10px;box-sizing:border-box}
|
||||
.lh-head{display:flex;align-items:center;gap:10px}
|
||||
.toggle-btn{cursor:pointer;user-select:none;display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--accent);background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:3px 10px;text-transform:none;letter-spacing:0}
|
||||
.toggle-btn:hover{border-color:var(--accent)}
|
||||
.toggle-btn .caret{font-size:11px;line-height:1}
|
||||
.sec-body.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="brand">
|
||||
{{LOGO_HTML}}
|
||||
<h1>{{REPORT_NAME}}<small>HBlink3 Monitor</small></h1>
|
||||
</div>
|
||||
<div id="conn" class="badge down">HBlink: connecting…</div>
|
||||
</header>
|
||||
|
||||
<section id="lastHeardSection"><h2 class="lh-head"><span id="lastHeardTitle">Last Heard</span> <span id="lastHeardToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Active only</span></span></h2><div id="lastHeardBody"><div id="lastHeard"></div></div></section>
|
||||
<section id="serversSection"><h2 class="lh-head">Server Systems <span id="serversToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="serversBody" class="sec-body"><div id="servers"></div></div></section>
|
||||
<section id="outboundsSection"><h2 class="lh-head">Outbound Systems <span id="outboundsToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="outboundsBody" class="sec-body"><div id="outbounds"></div></div></section>
|
||||
<section id="openbridgesSection"><h2 class="lh-head">OpenBridge Systems <span id="openbridgesToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="openbridgesBody" class="sec-body"><div id="openbridges"></div></div></section>
|
||||
<section id="bridgesSection"><h2 class="lh-head">Conference Bridges <span id="bridgesToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="bridgesBody" class="sec-body"><div id="bridges"></div></div></section>
|
||||
<section id="logSection"><h2 class="lh-head">Call Log <span id="logToggle" class="toggle-btn"><span class="caret">▾</span><span class="toggle-label">Hide</span></span></h2><div id="logBody" class="sec-body"><div id="log"></div></div></section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ST = { systems:{}, bridges:{}, active:{}, hang:{}, log:[], hblink:false, ping_time:5, max_missed:3, ping_loss_warn:5 };
|
||||
const STALE = 300000; // backstop: drop an active call this many ms after START if no END arrives (well above amateur TOT)
|
||||
const LAST_HEARD_CFG = '{{LAST_HEARD}}'; // 'open' | 'closed' | 'off'
|
||||
const LAST_HEARD_COUNT = parseInt('{{LAST_HEARD_COUNT}}', 10) || 10;
|
||||
const SERVER_REPEATERS_CFG = '{{SERVER_REPEATERS}}'; // 'open' | 'closed' — default state for server-system repeater lists
|
||||
|
||||
// ---------- helpers ----------
|
||||
const el = id => document.getElementById(id);
|
||||
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
function dur(secs){
|
||||
secs=Math.max(0,Math.floor(secs));
|
||||
const d=Math.floor(secs/86400), h=Math.floor(secs%86400/3600), m=Math.floor(secs%3600/60), s=secs%60;
|
||||
if(d) return d+'d '+h+'h'; if(h) return h+'h '+m+'m'; if(m) return m+'m '+s+'s'; return s+'s';
|
||||
}
|
||||
const skey = e => e.system+'|'+e.slot+'|'+e.stream_id;
|
||||
function fmtSlots(s){ return s==='3'||s===3?'Both':s==='1'||s===1?'TS1':s==='2'||s===2?'TS2':esc(String(s||'')); }
|
||||
const idName = (name,id)=> name ? esc(name)+' <span class="sub mono">('+id+')</span>' : '<span class="mono">'+id+'</span>';
|
||||
const callShort = ct => ct==='GROUP VOICE'?'Group Voice':ct==='UNIT VOICE'?'Unit Voice':ct==='GROUP DATA'?'Group Data':ct==='UNIT DATA'?'Unit Data':esc(ct);
|
||||
|
||||
// active call(s) for a system, by slot (most recent wins)
|
||||
function activeBySlot(sys){ const r={1:null,2:null}; for(const k in ST.active){const e=ST.active[k]; if(e.system===sys) r[e.slot]=e;} return r; }
|
||||
function activeList(sys){ const a=[]; for(const k in ST.active){const e=ST.active[k]; if(e.system===sys) a.push(e);} return a; }
|
||||
|
||||
function callCell(e, klass, hang){
|
||||
if(!e && hang){
|
||||
const tg = hang.dst_alias ? esc(hang.dst_alias) : esc(String(hang.dst||''));
|
||||
const left = Math.max(0, hang._expires - Date.now()/1000);
|
||||
return '<div class="call hang">'
|
||||
+ '<div class="call-top">'+esc(hang.trx)+' hang → '+tg+'</div>'
|
||||
+ '<div class="sub mono"><span class="js-countdown" data-rem="'+left.toFixed(1)+'" data-arr="'+Date.now()+'">'+dur(left)+'</span> remaining</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
if(!e) return '<div class="call idle"><div class="call-top">— idle —</div><div class="sub mono"> </div></div>';
|
||||
const src = e.src_alias ? esc(e.src_alias) : e.src;
|
||||
const dst = e.dst_alias ? esc(e.dst_alias) : e.dst;
|
||||
const d='<span class="js-dur" data-start="'+e._arrived+'">'+dur((Date.now()-e._arrived)/1000)+'</span>';
|
||||
return '<div class="call '+klass+'">'
|
||||
+ '<div class="call-top">'+src+' → '+dst+'</div>'
|
||||
+ '<div class="sub mono">'+esc(e.trx)+' · '+callShort(e.call_type)+' · '+e.src+' · '+d+'</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// ---------- renderers ----------
|
||||
// ---- per-server-system repeater-list collapse (persisted per browser; SERVER_REPEATERS sets the default) ----
|
||||
function srvCollapsedStore(){ try{ return JSON.parse(localStorage.getItem('hbl_srv_collapsed')||'{}'); }catch(e){ return {}; } }
|
||||
function srvIsCollapsed(n){
|
||||
const st = srvCollapsedStore();
|
||||
return Object.prototype.hasOwnProperty.call(st, n) ? !!st[n] : (SERVER_REPEATERS_CFG==='closed');
|
||||
}
|
||||
function srvSetCollapsed(n, val){
|
||||
const st = srvCollapsedStore(); st[n] = val;
|
||||
localStorage.setItem('hbl_srv_collapsed', JSON.stringify(st));
|
||||
}
|
||||
function initServersCollapse(){
|
||||
// Delegated so it survives renderServers() rebuilding #servers on every event.
|
||||
el('servers').addEventListener('click', ev=>{
|
||||
const btn = ev.target.closest('.srv-toggle'); if(!btn) return;
|
||||
const block = btn.closest('.sys-block'); if(!block) return;
|
||||
const n = block.dataset.sys;
|
||||
srvSetCollapsed(n, !srvIsCollapsed(n));
|
||||
renderServers();
|
||||
});
|
||||
}
|
||||
|
||||
function renderServers(){
|
||||
const names = Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='SERVER');
|
||||
if(!names.length){ el('servers').innerHTML='<div class="empty">No server systems</div>'; return; }
|
||||
let h='';
|
||||
for(const n of names){
|
||||
const m=ST.systems[n], repeaters=m.REPEATERS||{}, act=activeBySlot(n);
|
||||
const pids=Object.keys(repeaters);
|
||||
// System-level slot pill, shown only when the repeater table is collapsed.
|
||||
// Coloured by the call's own direction (system view has no per-repeater source).
|
||||
const sysPill=(slot)=>{ const e=act[slot], hang=ST.hang[n+'|'+slot]||null;
|
||||
return callCell(e||null, e?(e.trx==='TX'?'tx':'rx'):null, hang); };
|
||||
const collapsed=srvIsCollapsed(n);
|
||||
h+='<div class="sys-block'+(collapsed?' collapsed':'')+'" data-sys="'+esc(n)+'">';
|
||||
h+='<div class="sys-row"><h3><span class="sysname">'+esc(n)+'</span> <span class="pill">'+(m.REPEAT?'repeat':'isolate')+'</span> <span class="sub">'+pids.length+' repeater(s)</span> '
|
||||
+'<span class="toggle-btn srv-toggle" title="Show or hide this system’s connections"><span class="caret">'+(collapsed?'▸':'▾')+'</span>'+(collapsed?'Show Connections':'Hide Connections')+'</span></h3>'
|
||||
+'<div class="ts-hdrs"><div class="ts-hdr">TS1</div><div class="ts-hdr">TS2</div></div></div>'
|
||||
+'<div class="sys-pills"><div class="slotcol">'+sysPill(1)+'</div><div class="slotcol">'+sysPill(2)+'</div></div>';
|
||||
h+='<table><tr><th>Repeater</th><th>Connection</th><th>Freq</th><th>Slots</th><th class="slotcol"></th><th class="slotcol"></th></tr>';
|
||||
if(!pids.length) h+='<tr><td colspan="6" class="sub">No repeaters connected</td></tr>';
|
||||
for(const pid of pids){
|
||||
const p=repeaters[pid];
|
||||
const cell=(slot)=>{ const e=act[slot], hang=ST.hang[n+'|'+slot]||null;
|
||||
if(!e) return callCell(null, null, hang);
|
||||
// Colour purely by direction: the call's own RX/TX for the source repeater,
|
||||
// TX (red) for the others, which are hearing the repeated traffic.
|
||||
return callCell(e, String(e.peer)===String(p.RADIO_ID) ? (e.trx==='TX'?'tx':'rx') : 'tx', hang); };
|
||||
const freq = (p.RX_FREQ&&p.RX_FREQ.slice(0,3)!=='000') ? 'RX '+fmtFreq(p.RX_FREQ)+'<br>TX '+fmtFreq(p.TX_FREQ) : '<span class="sub">N/A</span>';
|
||||
// Chronic connectivity: PING_LOSS is a slow, decaying ping-loss % computed
|
||||
// server-side. A repeater that stays connected but loses pings over time
|
||||
// (choppy/lossy link) shows a rising loss %; a clean one sits at 0.
|
||||
const loss = p.PING_LOSS || 0;
|
||||
const csStyle = loss >= ST.ping_loss_warn ? ' style="color:var(--warn)"' : '';
|
||||
const lossTag = loss > 0 ? ' <span class="sub">· '+loss+'% ping loss</span>' : '';
|
||||
h+='<tr><td class="sysname"'+csStyle+'>'+idName(p.CALLSIGN,p.RADIO_ID)+'<div class="sub">'+esc(p.LOCATION||'')+lossTag+'</div></td>'+
|
||||
'<td class="mono">'+esc(p.IP)+':'+esc(p.PORT)+'<div class="sub">up <span class="js-since" data-base="'+(p.connected_secs||0)+'" data-arr="'+Date.now()+'">'+dur(p.connected_secs||0)+'</span></div></td>'+
|
||||
'<td class="mono sub">'+freq+'</td><td class="mono">'+fmtSlots(p.SLOTS)+'</td>'+
|
||||
'<td>'+cell(1)+'</td><td>'+cell(2)+'</td></tr>';
|
||||
}
|
||||
h+='</table>';
|
||||
h+='</div>'; // close .sys-block (collapsible repeater list)
|
||||
}
|
||||
el('servers').innerHTML=h;
|
||||
}
|
||||
function fmtFreq(f){ return f && f.length>=7 ? (f.slice(0,3)+'.'+f.slice(3,7)) : esc(f); }
|
||||
|
||||
function renderOutbounds(){
|
||||
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='OUTBOUND');
|
||||
if(!names.length){ el('outbounds').innerHTML='<div class="empty">No outbound systems</div>'; return; }
|
||||
let h='';
|
||||
for(const n of names){
|
||||
const s=ST.systems[n], st=s.STATS||{}, act=activeBySlot(n);
|
||||
const up = st.CONNECTION==='YES';
|
||||
const warn = (st.NUM_OUTSTANDING||0) > 0;
|
||||
const nameStyle = warn ? ' style="color:var(--warn)"' : '';
|
||||
const since = st.connected_secs!=null ? '<span class="js-since" data-base="'+st.connected_secs+'" data-arr="'+Date.now()+'">'+dur(st.connected_secs)+'</span>' : '—';
|
||||
// Single-entry system: fold the name into the header row (col 1) and the
|
||||
// TS1/TS2 labels into the slot-column headers, dropping the separate sys-row.
|
||||
h+='<table><tr><th class="sysname"'+nameStyle+'>'+esc(n)+'</th><th>Status</th><th>Slots</th><th class="slotcol">TS1</th><th class="slotcol">TS2</th></tr>';
|
||||
h+='<tr><td>'+idName(s.CALLSIGN,s.RADIO_ID)+'<div class="sub">'+esc(s.LOCATION||'')+' · server '+esc(s.SERVER_IP)+':'+esc(s.SERVER_PORT)+'</div></td>'+
|
||||
'<td><span class="dot '+(up?'ok':'bad')+'"></span><span class="'+(up?'status-ok':'status-bad')+'">'+(up?'Connected':'Disconnected')+'</span><div class="sub">up '+since+'</div></td>'+
|
||||
'<td class="mono">'+fmtSlots(s.SLOTS)+'</td>'+
|
||||
'<td>'+callCell(act[1], act[1]&&act[1].trx==='TX'?'tx':'rx', ST.hang[n+'|1']||null)+'</td>'+
|
||||
'<td>'+callCell(act[2], act[2]&&act[2].trx==='TX'?'tx':'rx', ST.hang[n+'|2']||null)+'</td></tr>';
|
||||
h+='</table>';
|
||||
}
|
||||
el('outbounds').innerHTML=h;
|
||||
}
|
||||
|
||||
function renderOpenbridges(){
|
||||
const names=Object.keys(ST.systems).filter(n=>ST.systems[n].MODE==='OPENBRIDGE');
|
||||
if(!names.length){ el('openbridges').innerHTML='<div class="empty">No OpenBridge systems</div>'; return; }
|
||||
let h='<table><tr><th>System</th><th>Network / Target</th><th>Active Streams</th></tr>';
|
||||
for(const n of names){
|
||||
const o=ST.systems[n], streams=activeList(n);
|
||||
const cells = streams.length ? streams.map(e=>'<span class="call '+(e.trx==='TX'?'tx':'rx')+'" style="display:inline-block;width:auto;margin:2px">'+e.trx+' · '+idName(e.src_alias,e.src)+' → '+idName(e.dst_alias,e.dst)+'</span>').join(' ') : '<span class="call idle" style="display:inline-block;width:auto">— idle —</span>';
|
||||
h+='<tr><td class="sysname">'+esc(n)+'</td><td class="mono">ID '+esc(o.NETWORK_ID)+'<div class="sub">'+esc(o.TARGET_IP)+':'+esc(o.TARGET_PORT)+'</div></td><td>'+cells+'</td></tr>';
|
||||
}
|
||||
el('openbridges').innerHTML=h+'</table>';
|
||||
}
|
||||
|
||||
function renderBridges(){
|
||||
const names=Object.keys(ST.bridges);
|
||||
if(!names.length){ el('bridges').innerHTML='<div class="empty">No conference bridges (or running bridge_all / hblink standalone)</div>'; return; }
|
||||
// One shared table for all bridges so every column lines up across them
|
||||
// (each bridge gets a full-width sub-header row). Per-bridge tables auto-size
|
||||
// their columns independently, which misaligns Action width and wide TGIDs.
|
||||
let h='<table class="bridges"><tr><th>System</th><th>TS</th><th>TGID</th><th>Status</th><th>Timeout</th><th>Timer</th><th>Timeout Action</th><th>Activate TGIDs</th><th>Deactivate TGIDs</th></tr>';
|
||||
for(const b of names){
|
||||
h+='<tr class="bridge-head"><td colspan="9">'+esc(b)+'</td></tr>';
|
||||
for(const m of ST.bridges[b]){
|
||||
const on=m.ACTIVE;
|
||||
const timerRunning = (m.TO_TYPE==='ON' && m.ACTIVE) || (m.TO_TYPE==='OFF' && !m.ACTIVE);
|
||||
const remS = timerRunning ? (m.remaining==null ? 0 : m.remaining) : null;
|
||||
const rem = remS!=null ? '<span class="js-countdown mono" data-rem="'+remS.toFixed(1)+'" data-arr="'+Date.now()+'">'+(remS<=0?'expired':dur(remS))+'</span>'
|
||||
: '<span class="sub">N/A</span>';
|
||||
const act = m.TO_TYPE==='ON'?'Disconnect after timer':m.TO_TYPE==='OFF'?'Reconnect after timer':'<span class="sub">None</span>';
|
||||
h+='<tr><td class="sysname">'+esc(m.SYSTEM)+'</td><td class="mono">'+m.TS+'</td><td class="mono">'+idName(m.TGID_NAME,m.TGID)+'</td>'+
|
||||
'<td><span class="'+(on?'status-ok':'status-bad')+'">'+(on?'Active':'Inactive')+'</span></td>'+
|
||||
'<td class="mono sub">'+(m.TO_TYPE!=='NONE'?Math.round(m.TIMEOUT/60)+'m':'<span class="sub">N/A</span>')+'</td>'+
|
||||
'<td>'+rem+'</td><td class="sub">'+act+'</td><td class="mono sub">'+(m.ON||[]).join(', ')+'</td><td class="mono sub">'+(m.OFF||[]).join(', ')+'</td></tr>';
|
||||
}
|
||||
}
|
||||
el('bridges').innerHTML=h+'</table>';
|
||||
}
|
||||
|
||||
function logRow(e){
|
||||
const t=new Date(e._arrived||Date.now()).toLocaleTimeString();
|
||||
const cls=e.action==='START'?'start':'end';
|
||||
const d=e.duration!=null?(' · '+e.duration.toFixed(1)+'s'):'';
|
||||
return '<div><span class="ts">'+t+'</span> <span class="'+cls+'">'+callShort(e.call_type)+' '+e.action+'</span> · '+
|
||||
esc(e.system)+' · '+idName(e.src_alias,e.src)+' TS'+e.slot+' → '+idName(e.dst_alias,e.dst)+d+'</div>';
|
||||
}
|
||||
function renderLog(){ el('log').innerHTML = ST.log.map(logRow).join(''); }
|
||||
|
||||
// ---------- last heard ----------
|
||||
// Most-recent unique callers, derived from the RX (ingress) call log — no server
|
||||
// state of its own. Deduped by source radio ID, newest first.
|
||||
function lhActiveSrc(src){ for(const k in ST.active){ if(String(ST.active[k].src)===String(src)) return true; } return false; }
|
||||
function renderLastHeard(){
|
||||
const box = el('lastHeard'); if(!box) return;
|
||||
const seen = {}, rows = [];
|
||||
for(const e of ST.log){
|
||||
const id = e.src==null ? '' : String(e.src);
|
||||
if(!id || seen[id]) continue;
|
||||
if(lastHeardCollapsed && !lhActiveSrc(e.src)) continue; // "Active Calls" view: live streams only
|
||||
seen[id] = 1; rows.push(e);
|
||||
if(rows.length >= LAST_HEARD_COUNT) break;
|
||||
}
|
||||
if(!rows.length){ box.innerHTML = '<div class="empty">'+(lastHeardCollapsed?'No active calls':'No calls heard yet')+'</div>'; return; }
|
||||
let h = '<table><tr><th>User</th><th>Type</th><th>Source</th><th>TS</th><th>Talkgroup</th><th>When</th></tr>';
|
||||
for(const e of rows){
|
||||
const src = e.peer!=null ? idName(e.peer_alias, e.peer) : esc(e.system||'');
|
||||
const t = e._ts ? e._ts*1000 : (e._arrived||Date.now());
|
||||
const when = lhActiveSrc(e.src)
|
||||
? '<span class="status-ok">Active</span>'
|
||||
: '<span class="js-lh mono" data-t="'+t+'">'+dur((Date.now()-t)/1000)+' ago</span>';
|
||||
h += '<tr><td>'+idName(e.src_alias,e.src)+'</td>'
|
||||
+ '<td class="sub">'+callShort(e.call_type)+'</td>'
|
||||
+ '<td class="mono sub">'+src+'</td>'
|
||||
+ '<td class="mono">TS'+esc(e.slot||'?')+'</td>'
|
||||
+ '<td>'+idName(e.dst_alias,e.dst)+'</td>'
|
||||
+ '<td>'+when+'</td></tr>';
|
||||
}
|
||||
box.innerHTML = h+'</table>';
|
||||
}
|
||||
// Collapsing Last Heard doesn't hide it — it filters to an "Active Calls" view
|
||||
// (live streams only) and retitles the header. Expanded shows the full list.
|
||||
let lastHeardCollapsed = false;
|
||||
function applyLastHeard(collapsed){
|
||||
lastHeardCollapsed = collapsed;
|
||||
el('lastHeardTitle').textContent = collapsed ? 'Active Calls' : 'Last Heard';
|
||||
const t = el('lastHeardToggle');
|
||||
t.querySelector('.caret').textContent = collapsed ? '▸' : '▾';
|
||||
t.querySelector('.toggle-label').textContent = collapsed ? 'Show all' : 'Active only';
|
||||
renderLastHeard();
|
||||
}
|
||||
function initLastHeard(){
|
||||
const sec = el('lastHeardSection'); if(!sec) return;
|
||||
if(LAST_HEARD_CFG==='off'){ sec.style.display='none'; return; }
|
||||
const saved = localStorage.getItem('lastHeardOpen'); // '1' = full list, '0' = active-only; else config default
|
||||
applyLastHeard(saved==null ? (LAST_HEARD_CFG==='closed') : (saved==='0'));
|
||||
el('lastHeardToggle').addEventListener('click', ()=>{
|
||||
const collapsed = !lastHeardCollapsed;
|
||||
applyLastHeard(collapsed);
|
||||
localStorage.setItem('lastHeardOpen', collapsed?'0':'1');
|
||||
});
|
||||
}
|
||||
// Simple collapsible sections (hide the body). Persisted per browser.
|
||||
function initCollapse(key, bodyId, toggleId, defCollapsed){
|
||||
const body = el(bodyId), btn = el(toggleId);
|
||||
if(!body || !btn) return;
|
||||
const apply = c => {
|
||||
body.classList.toggle('hidden', c);
|
||||
btn.querySelector('.caret').textContent = c ? '▸' : '▾';
|
||||
btn.querySelector('.toggle-label').textContent = c ? 'Show' : 'Hide';
|
||||
};
|
||||
const saved = localStorage.getItem('collapse:'+key); // '1' = collapsed, '0' = shown; else default
|
||||
apply(saved==null ? !!defCollapsed : saved==='1');
|
||||
btn.addEventListener('click', ()=>{
|
||||
const collapsed = !body.classList.contains('hidden'); // currently shown → we're collapsing
|
||||
localStorage.setItem('collapse:'+key, collapsed?'1':'0');
|
||||
apply(collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- live tick ----------
|
||||
setInterval(()=>{
|
||||
const now=Date.now();
|
||||
// reap stale active streams whose END never arrived (interrupted/lost calls)
|
||||
let reaped=false;
|
||||
for(const k in ST.active){ if(now-ST.active[k]._arrived>STALE){ delete ST.active[k]; reaped=true; } }
|
||||
for(const k in ST.hang) { if(now/1000 > ST.hang[k]._expires) { delete ST.hang[k]; reaped=true; } }
|
||||
if(reaped) renderAll();
|
||||
document.querySelectorAll('.js-since').forEach(s=>{ s.textContent=dur((+s.dataset.base)+(now-(+s.dataset.arr))/1000); });
|
||||
document.querySelectorAll('.js-dur').forEach(s=>{ s.textContent=dur((now-(+s.dataset.start))/1000); });
|
||||
document.querySelectorAll('.js-countdown').forEach(s=>{ const r=(+s.dataset.rem)-(now-(+s.dataset.arr))/1000; s.textContent=r<=0?'expired':dur(r); });
|
||||
document.querySelectorAll('.js-lh').forEach(s=>{ s.textContent=dur((now-(+s.dataset.t))/1000)+' ago'; });
|
||||
},1000);
|
||||
|
||||
// ---------- websocket ----------
|
||||
function setConn(up){ ST.hblink=up; const c=el('conn'); c.className='badge '+(up?'up':'down'); c.textContent='HBlink: '+(up?'connected':'disconnected'); }
|
||||
function renderAll(){ renderServers(); renderOutbounds(); renderOpenbridges(); renderBridges(); renderLog(); renderLastHeard(); }
|
||||
|
||||
function applyStream(e){
|
||||
e._arrived = e._arrived || Date.now();
|
||||
const hkey = e.system+'|'+e.slot;
|
||||
if(e.action==='START'){
|
||||
delete ST.hang[hkey];
|
||||
ST.active[skey(e)]=e;
|
||||
} else {
|
||||
delete ST.active[skey(e)];
|
||||
const ht = (ST.systems[e.system]||{}).GROUP_HANGTIME||0;
|
||||
if(ht>0) ST.hang[hkey] = Object.assign({}, e, {_expires: Date.now()/1000 + ht});
|
||||
}
|
||||
if(e.trx==='RX'){ ST.log.unshift(e); if(ST.log.length>400) ST.log.pop(); renderLog(); renderLastHeard(); }
|
||||
// re-render the affected system class
|
||||
const sys=ST.systems[e.system];
|
||||
if(sys&&sys.MODE==='SERVER') renderServers();
|
||||
else if(sys&&sys.MODE==='OUTBOUND') renderOutbounds();
|
||||
else if(sys&&sys.MODE==='OPENBRIDGE') renderOpenbridges();
|
||||
else renderServers();
|
||||
}
|
||||
|
||||
let sock=null;
|
||||
function connect(){
|
||||
sock=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||
sock.onmessage=ev=>{
|
||||
const m=JSON.parse(ev.data);
|
||||
if(m.type==='initial'){ setConn(m.hblink); ST.systems=m.systems||{}; ST.bridges=m.bridges||{};
|
||||
if(m.ping_time) ST.ping_time=m.ping_time; if(m.max_missed) ST.max_missed=m.max_missed; if(m.ping_loss_warn!=null) ST.ping_loss_warn=m.ping_loss_warn;
|
||||
ST.active={}; (m.streams||[]).forEach(s=>{s._arrived=Date.now(); ST.active[skey(s)]=s;});
|
||||
ST.log=(m.log||[]).map(e=>{e._arrived=e._arrived||Date.now();return e;}); renderAll(); }
|
||||
else if(m.type==='hblink'){ setConn(m.connected); if(!m.connected){ST.systems={};ST.bridges={};ST.active={};renderAll();} }
|
||||
else if(m.type==='config'){ ST.systems=m.systems||{};
|
||||
if(m.ping_time) ST.ping_time=m.ping_time; if(m.max_missed) ST.max_missed=m.max_missed; if(m.ping_loss_warn!=null) ST.ping_loss_warn=m.ping_loss_warn;
|
||||
renderServers(); renderOutbounds(); renderOpenbridges(); }
|
||||
else if(m.type==='bridges'){ ST.bridges=m.bridges||{}; renderBridges(); }
|
||||
else if(m.type==='stream_start'||m.type==='stream_end'){ applyStream(m); }
|
||||
};
|
||||
sock.onclose=()=>{ setConn(false); el('conn').textContent='HBlink: dashboard offline'; setTimeout(connect,2000); };
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo mode (?demo in URL) — coherent static scenario, no WebSocket.
|
||||
// Traffic paradigm:
|
||||
// US-NATIONWIDE (TGID 31, TS1): W0ABC keyed up on KS-SERVER →
|
||||
// KS-SERVER TS1 RX, KC-OUTBOUND TS1 TX, BRANDMEISTER TS1 TX
|
||||
// KS-STATEWIDE (TGID 9, TS2): N0CAL transmitting via BrandMeister →
|
||||
// BRANDMEISTER TS2 RX, KS-SERVER TS2 TX to all repeaters
|
||||
// MO-LOCAL (TGID 3100, TS2): W0DEF just finished →
|
||||
// KC-OUTBOUND TS2 in TX hang
|
||||
// ---------------------------------------------------------------------------
|
||||
if(new URLSearchParams(location.search).has('demo')){
|
||||
const _t = Date.now(); // ms (for _arrived, durations)
|
||||
const _ts = _t / 1000; // seconds (for hang _expires)
|
||||
|
||||
ST.systems = {
|
||||
'KS-SERVER': {
|
||||
MODE:'SERVER', GROUP_HANGTIME:5, REPEAT:true,
|
||||
REPEATERS:{
|
||||
'3112001':{ RADIO_ID:'3112001', CALLSIGN:'W0ABC', LOCATION:'Topeka, KS', RX_FREQ:'448800000', TX_FREQ:'443800000', SLOTS:'3', IP:'10.1.1.1', PORT:50000, connected_secs:3672, last_ping_secs:2.1, PING_LOSS:0 },
|
||||
'3112002':{ RADIO_ID:'3112002', CALLSIGN:'KD0XYZ', LOCATION:'Lawrence, KS', RX_FREQ:'446525000', TX_FREQ:'441525000', SLOTS:'3', IP:'10.1.1.2', PORT:50000, connected_secs:1245, last_ping_secs:3.8, PING_LOSS:3 },
|
||||
'3112003':{ RADIO_ID:'3112003', CALLSIGN:'N0MJS', LOCATION:'Wichita, KS', RX_FREQ:'449500000', TX_FREQ:'444500000', SLOTS:'3', IP:'10.1.1.3', PORT:50000, connected_secs:412, last_ping_secs:8.3, PING_LOSS:24 },
|
||||
},
|
||||
},
|
||||
'KC-OUTBOUND': {
|
||||
MODE:'OUTBOUND', GROUP_HANGTIME:5,
|
||||
CALLSIGN:'K0ABD', RADIO_ID:'3129200', LOCATION:'Kansas City, MO',
|
||||
SERVER_IP:'10.5.0.1', SERVER_PORT:54001, SLOTS:'3',
|
||||
STATS:{ CONNECTION:'YES', NUM_OUTSTANDING:0, connected_secs:5412 },
|
||||
},
|
||||
'BRANDMEISTER': {
|
||||
MODE:'OPENBRIDGE',
|
||||
NETWORK_ID:'3129100', TARGET_IP:'bm.brandmeister.network', TARGET_PORT:62031,
|
||||
},
|
||||
};
|
||||
|
||||
ST.bridges = {
|
||||
'US-NATIONWIDE':[
|
||||
{ SYSTEM:'KS-SERVER', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
{ SYSTEM:'KC-OUTBOUND', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
{ SYSTEM:'BRANDMEISTER', TS:1, TGID:31, TGID_NAME:'US Nationwide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
],
|
||||
'KS-STATEWIDE':[
|
||||
{ SYSTEM:'KS-SERVER', TS:2, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
{ SYSTEM:'BRANDMEISTER', TS:2, TGID:9, TGID_NAME:'KS Statewide', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
],
|
||||
'MO-LOCAL':[
|
||||
{ SYSTEM:'KS-SERVER', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
{ SYSTEM:'KC-OUTBOUND', TS:2, TGID:3100, TGID_NAME:'MO Local', ACTIVE:true, TO_TYPE:'NONE', TIMEOUT:0, TIMER:0, ON:[], OFF:[], RESET:[], remaining:null },
|
||||
],
|
||||
};
|
||||
|
||||
// Active streams — keyed by system|slot|stream_id
|
||||
// W0ABC on US Nationwide TS1 (started 5s ago); N0CAL on KS Statewide TS2 (started 3s ago).
|
||||
// _arrived in ms; keepalive window is 30s so setting recently to prevent demo-mode reaping.
|
||||
ST.active = {
|
||||
'KS-SERVER|1|aabb0011': { system:'KS-SERVER', slot:1, stream_id:'aabb0011', trx:'RX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
|
||||
'KS-SERVER|2|ccdd0022': { system:'KS-SERVER', slot:2, stream_id:'ccdd0022', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide', peer:9990001, _arrived:_t-3000 },
|
||||
'KC-OUTBOUND|1|aabb0011': { system:'KC-OUTBOUND', slot:1, stream_id:'aabb0011', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
|
||||
'BRANDMEISTER|1|aabb0011': { system:'BRANDMEISTER', slot:1, stream_id:'aabb0011', trx:'TX', action:'START', call_type:'GROUP VOICE', src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide', peer:3112001, _arrived:_t-5000 },
|
||||
'BRANDMEISTER|2|ccdd0022': { system:'BRANDMEISTER', slot:2, stream_id:'ccdd0022', trx:'RX', action:'START', call_type:'GROUP VOICE', src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide', peer:3188765, _arrived:_t-3000 },
|
||||
};
|
||||
|
||||
// KC-OUTBOUND TS2 in TX hang — W0DEF's MO Local call ended 4s ago, 5s hangtime
|
||||
ST.hang = {
|
||||
'KC-OUTBOUND|2':{ trx:'TX', dst:3100, dst_alias:'MO Local', _expires:_ts+26,
|
||||
system:'KC-OUTBOUND', slot:2, call_type:'GROUP VOICE', src:3161001, _arrived:_t-4000 },
|
||||
};
|
||||
|
||||
// Call log, most-recent first; _arrived in ms
|
||||
ST.log = [
|
||||
{ _arrived:_t-3000, action:'START', call_type:'GROUP VOICE', system:'BRANDMEISTER', slot:2, src:3188765, src_alias:'N0CAL', dst:9, dst_alias:'KS Statewide' },
|
||||
{ _arrived:_t-5000, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112001, src_alias:'W0ABC', dst:31, dst_alias:'US Nationwide' },
|
||||
{ _arrived:_t-4000, action:'END', call_type:'GROUP VOICE', system:'KC-OUTBOUND', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local', duration:14.2 },
|
||||
{ _arrived:_t-4000, action:'END', call_type:'GROUP VOICE', system:'KS-SERVER', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local', duration:14.2 },
|
||||
{ _arrived:_t-18200, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:2, src:3161001, src_alias:'W0DEF', dst:3100, dst_alias:'MO Local' },
|
||||
{ _arrived:_t-129000, action:'END', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112002, src_alias:'KD0XYZ', dst:31, dst_alias:'US Nationwide', duration:8.7 },
|
||||
{ _arrived:_t-137700, action:'START', call_type:'GROUP VOICE', system:'KS-SERVER', slot:1, src:3112002, src_alias:'KD0XYZ', dst:31, dst_alias:'US Nationwide' },
|
||||
];
|
||||
|
||||
setConn(true);
|
||||
renderAll();
|
||||
// No WebSocket in demo mode — setInterval ticker still runs for live timers.
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
initLastHeard();
|
||||
initServersCollapse();
|
||||
initCollapse('servers','serversBody','serversToggle', false);
|
||||
initCollapse('outbounds','outboundsBody','outboundsToggle', false);
|
||||
initCollapse('openbridges','openbridgesBody','openbridgesToggle', false);
|
||||
initCollapse('bridges','bridgesBody','bridgesToggle', false);
|
||||
initCollapse('log','logBody','logToggle', false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"talkgroups": [
|
||||
{"id": 91, "callsign": "Worldwide"},
|
||||
{"id": 93, "callsign": "NA"},
|
||||
{"id": 3100, "callsign": "Nationwide"},
|
||||
{"id": 3101, "callsign": "TAC 310"},
|
||||
{"id": 3102, "callsign": "TAC 311"},
|
||||
{"id": 3174, "callsign": "Kansas City"},
|
||||
{"id": 3170001, "callsign": "KS Statewide"},
|
||||
{"id": 3170009, "callsign": "KS West"},
|
||||
{"id": 9, "callsign": "Local 9"},
|
||||
{"id": 99, "callsign": "PARROT"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# Conference Bridge State Machine for C Reimplementation
|
||||
|
||||
## Overview
|
||||
|
||||
Each conference bridge is a named group of system members. Each member independently
|
||||
tracks whether it is ACTIVE or INACTIVE. When a member is ACTIVE, traffic received on
|
||||
its configured system, timeslot, and TGID is eligible to be forwarded to all other ACTIVE
|
||||
members of the same bridge.
|
||||
|
||||
The state machine described here governs how individual members transition between ACTIVE
|
||||
and INACTIVE, and how timers interact with those transitions.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
A bridge member holds the following state:
|
||||
|
||||
- **SYSTEM**: the name of the HBP system this member belongs to
|
||||
- **TS**: the timeslot (1 or 2) on that system
|
||||
- **TGID**: the talkgroup ID that traffic on this member carries
|
||||
- **ACTIVE**: boolean, whether this member is currently passing traffic
|
||||
- **TO_TYPE**: one of ON, OFF, or NONE — controls timer behavior (see below)
|
||||
- **TIMEOUT**: integer minutes — the duration of the timer, as configured. Converted to
|
||||
seconds internally for comparison against the running timer.
|
||||
- **TIMER**: an absolute time value in the same units as the application's time source.
|
||||
Represents the moment at which the timer expires. The C implementation may use any
|
||||
consistent time source (Unix epoch via time(), monotonic clock, or other); what matters
|
||||
is that TIMER and the current time are always compared using the same source. When
|
||||
emitted in the telemetry feed, TIMER is expressed as a Unix epoch value so that external
|
||||
consumers can compute a countdown without needing to know the application's internal time
|
||||
reference.
|
||||
- **ON**: list of TGIDs — receiving any of these on the matching TS triggers the ON action
|
||||
for this member (see TO_TYPE behavior below)
|
||||
- **OFF**: list of TGIDs — receiving any of these on the matching TS triggers the OFF action
|
||||
for this member (see TO_TYPE behavior below)
|
||||
- **RESET**: list of TGIDs — receiving any of these on the matching TS resets the timer
|
||||
for this member regardless of TO_TYPE
|
||||
|
||||
---
|
||||
|
||||
## TO_TYPE semantics
|
||||
|
||||
TO_TYPE controls the default state of a bridge member and what the timer expiry does.
|
||||
TO_TYPE ON and TO_TYPE OFF are logical inverses of each other.
|
||||
|
||||
**TO_TYPE ON**: the member is INACTIVE by default. Receiving an ON TGID activates it and
|
||||
starts the timer. The member stays ACTIVE until the timer expires, at which point it returns
|
||||
to INACTIVE. This is the "turn on for N minutes" behavior.
|
||||
|
||||
**TO_TYPE OFF**: the member is ACTIVE by default. Receiving an OFF TGID deactivates it and
|
||||
starts the timer. The member stays INACTIVE until the timer expires, at which point it
|
||||
returns to ACTIVE. This is the "turn off for N minutes" behavior — the logical inverse of
|
||||
TO_TYPE ON.
|
||||
|
||||
**TO_TYPE NONE**: no automatic timer. The member only changes state in response to ON and
|
||||
OFF TGID triggers. It stays in whatever state it is in indefinitely until a trigger arrives.
|
||||
|
||||
RESET always extends the timer by setting it to current_time + TIMEOUT_seconds regardless
|
||||
of TO_TYPE. For TO_TYPE ON this extends the active period. For TO_TYPE OFF this extends the
|
||||
deactivation period — keeping the member INACTIVE longer. This is valid behavior even if
|
||||
its usefulness in the OFF case is limited.
|
||||
|
||||
---
|
||||
|
||||
## State transitions
|
||||
|
||||
### ON trigger — fires at call START (new stream, voice header)
|
||||
|
||||
When a new stream is detected on a system (a stream_id not previously seen on that
|
||||
system/slot combination), the incoming TGID is checked against the ON list of every bridge
|
||||
member associated with that system and slot.
|
||||
|
||||
If the incoming TGID is in a member's ON list and the slot matches:
|
||||
|
||||
- For TO_TYPE ON: if INACTIVE, set ACTIVE to True and set TIMER to
|
||||
current_time + (TIMEOUT * 60). If already ACTIVE, reset TIMER to extend the countdown.
|
||||
- For TO_TYPE OFF: no effect from ON list while ACTIVE (already active). If somehow
|
||||
INACTIVE when an ON TGID arrives, treat it as a reset — set ACTIVE to True. The ON list
|
||||
is not the primary trigger for TO_TYPE OFF members.
|
||||
- For TO_TYPE NONE: set ACTIVE to True. No timer is set.
|
||||
|
||||
If the incoming TGID is in a member's RESET list and the slot matches: set TIMER to
|
||||
current_time + (TIMEOUT * 60) regardless of current ACTIVE state or TO_TYPE.
|
||||
|
||||
If any member's ACTIVE state changed, emit an immediate bridges snapshot.
|
||||
|
||||
**Why the ON trigger fires at call START, not call END**: the voice header frame must route
|
||||
through to target systems before any voice frames arrive. If the trigger were deferred to
|
||||
call END, the header and all voice frames would be lost and target radios would never hear
|
||||
the call. Activating at the first packet of a new stream allows the header and all
|
||||
subsequent frames to be forwarded in the same pass as the triggering call.
|
||||
|
||||
### OFF trigger — fires at call END (voice terminator received)
|
||||
|
||||
When a voice terminator is received for an active stream on a system, the incoming TGID is
|
||||
checked against the OFF list of every bridge member associated with that system and slot.
|
||||
|
||||
If the incoming TGID is in a member's OFF list and the slot matches:
|
||||
|
||||
- For TO_TYPE OFF: if ACTIVE, set ACTIVE to False and set TIMER to
|
||||
current_time + (TIMEOUT * 60). This begins the deactivation period.
|
||||
- For TO_TYPE ON: set ACTIVE to False immediately. No timer restart.
|
||||
- For TO_TYPE NONE: set ACTIVE to False. No timer.
|
||||
|
||||
If any member's ACTIVE state changed, emit an immediate bridges snapshot.
|
||||
|
||||
**Why the OFF trigger fires at call END**: the terminator frame must also route to target
|
||||
systems so they can cleanly close the call. Deactivating at the terminator ensures the full
|
||||
call — header through terminator — reaches all targets before routing stops.
|
||||
|
||||
### Timer expiry — fires in the rule timer loop
|
||||
|
||||
The rule timer loop runs every 10 seconds. The 10-second interval is sufficient because
|
||||
TIMEOUT is configured in whole minutes, making 10-second resolution more than adequate for
|
||||
correct expiry detection. The loop does not need sub-minute precision.
|
||||
|
||||
For each bridge member where TO_TYPE is ON or OFF:
|
||||
|
||||
- If TO_TYPE is ON and ACTIVE is True and TIMER is less than current_time: set ACTIVE to
|
||||
False. The member has exceeded its active timeout.
|
||||
- If TO_TYPE is OFF and ACTIVE is False and TIMER is less than current_time: set ACTIVE to
|
||||
True. The deactivation period has ended; the member returns to its default active state.
|
||||
|
||||
If any member changed state, emit a bridges snapshot. The loop does not emit a snapshot if
|
||||
no state changed, avoiding unnecessary traffic to reporting clients.
|
||||
|
||||
---
|
||||
|
||||
## Timer behavior summary
|
||||
|
||||
TO_TYPE ON: default INACTIVE. ON TGID activates, timer runs, expiry deactivates. RESET
|
||||
extends active period. Logical reading: "this bridge is on for N minutes after the last
|
||||
activity."
|
||||
|
||||
TO_TYPE OFF: default ACTIVE. OFF TGID deactivates, timer runs, expiry reactivates. RESET
|
||||
extends deactivation period. Logical reading: "this bridge is off for N minutes after the
|
||||
blocking call, then comes back."
|
||||
|
||||
TO_TYPE NONE: no timer. ON TGID activates, OFF TGID deactivates. Stays in either state
|
||||
indefinitely until triggered.
|
||||
|
||||
---
|
||||
|
||||
## Routing when ACTIVE
|
||||
|
||||
A packet received on a system/slot/TGID that matches an ACTIVE bridge member is forwarded
|
||||
to every other ACTIVE member of the same bridge, subject to the packet routing rules (see
|
||||
the routing flow document). The source system is never a forwarding target for its own
|
||||
traffic.
|
||||
|
||||
If a bridge has only one ACTIVE member, there are no targets and the packet is not forwarded
|
||||
anywhere (but it is still received and processed normally by the ingress system).
|
||||
|
||||
---
|
||||
|
||||
## Reporting bridge state
|
||||
|
||||
The bridge state is reported as a snapshot (the full state of all bridges) rather than as
|
||||
individual transitions. This means:
|
||||
|
||||
- Consumers always have a consistent view — they cannot observe a partial update
|
||||
- The core does not need to track which clients have seen which transitions
|
||||
- On new client connect, the current snapshot is sent immediately
|
||||
|
||||
Immediate snapshots on ACTIVE state change are important for PTT systems: a user keys up
|
||||
expecting to see the bridge connect, and a 10-second lag would be confusing. The immediate
|
||||
snapshot is surgical — it only fires when ACTIVE actually changed, not on every packet.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Packet Routing Flow for C Reimplementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the path a DMRD (DMR data) packet travels from arrival at a UDP
|
||||
socket through the routing decision to forwarding on one or more outbound UDP sockets. It
|
||||
also covers the LC rewriting policy and why it is designed the way it is.
|
||||
|
||||
---
|
||||
|
||||
## DMRD packet structure
|
||||
|
||||
All field offsets are zero-indexed bytes within the UDP payload.
|
||||
|
||||
- Bytes 0-3: command identifier, the ASCII string "DMRD"
|
||||
- Byte 4: sequence number (wraps 0-255)
|
||||
- Bytes 5-7: rf_src, the radio ID of the subscriber (transmitting radio), 3 bytes big-endian
|
||||
- Bytes 8-10: dst_id, the destination TGID for group calls or subscriber ID for unit calls,
|
||||
3 bytes big-endian
|
||||
- Bytes 11-14: peer_id, the radio ID of the repeater or hotspot that received the call,
|
||||
4 bytes big-endian
|
||||
- Byte 15: flags byte. Bit 7 (0x80): timeslot (0 = TS1, 1 = TS2). Bits 6-4: frame type.
|
||||
Bits 3-0: dtype_vseq, encodes the voice frame sequence position within a superframe.
|
||||
- Bytes 16-19: stream_id, a 4-byte identifier that is constant for the duration of one call
|
||||
and unique per call. Used to detect new streams and track active calls.
|
||||
- Bytes 20-52: 33 bytes of DMR payload (the actual over-the-air frame content, including
|
||||
the embedded LC or SYNC pattern depending on frame type)
|
||||
- Bytes 53-54: BER (bit error rate) and RSSI values from the repeater
|
||||
|
||||
Total: 55 bytes minimum. Some implementations may include additional trailing data.
|
||||
|
||||
---
|
||||
|
||||
## Arrival and validation
|
||||
|
||||
When a DMRD packet arrives on a SERVER system's UDP socket:
|
||||
|
||||
1. Confirm the command bytes (0-3) are "DMRD".
|
||||
2. Extract peer_id from bytes 11-14. Look up the peer_id in the system's repeater table.
|
||||
3. Validate that the repeater exists, its CONNECTION state is "YES" (fully registered), and
|
||||
its stored socket address matches the packet's source address. Drop the packet silently
|
||||
if any check fails. This prevents spoofed or stale packets from being routed.
|
||||
4. Extract rf_src (bytes 5-7), dst_id (bytes 8-10), the timeslot from bit 7 of byte 15,
|
||||
dtype_vseq from bits 3-0 of byte 15, and stream_id (bytes 16-19).
|
||||
5. Determine call type (group or unit) from the flags byte.
|
||||
|
||||
For OUTBOUND (peer) systems, the peer_id in the packet is the remote master's ID, not the
|
||||
local repeater's. Validation is against the outbound system's configured server address and
|
||||
connection state.
|
||||
|
||||
---
|
||||
|
||||
## New stream detection
|
||||
|
||||
Before the routing loop, check whether this stream_id has been seen before on this
|
||||
system/slot combination. The active stream table maps (system, slot) to the current
|
||||
stream_id.
|
||||
|
||||
If the stream_id is new (not in the active stream table):
|
||||
|
||||
1. Record the new stream_id in the active stream table for this system/slot.
|
||||
2. Run the ACTIVATION check: consult the bridge membership table for this system and slot.
|
||||
For each bridge member this system/slot belongs to, check whether dst_id is in the
|
||||
member's ON list. If it is, apply the activation logic described in the bridge state
|
||||
machine document. Do this before the routing loop so the triggering header frame itself
|
||||
is forwarded to newly activated targets.
|
||||
3. If any bridge member's ACTIVE state changed, emit an immediate bridges snapshot via the
|
||||
reporting feed.
|
||||
|
||||
---
|
||||
|
||||
## Routing loop
|
||||
|
||||
For each bridge that this system/slot/TGID combination belongs to (looked up via a
|
||||
precomputed BRIDGE_BY_SYSTEM index), and for each other ACTIVE member of that bridge
|
||||
(excluding the source system):
|
||||
|
||||
1. Determine the target: the other member's SYSTEM, TS, and TGID.
|
||||
2. Build the outbound packet by copying the inbound packet and rewriting:
|
||||
- Bytes 8-10 (dst_id): replace with the target member's TGID
|
||||
- Byte 15 (flags): set or clear bit 7 to reflect the target's TS
|
||||
- Bytes 11-14 (peer_id): replace with the target system's own configured radio ID
|
||||
(the remote master expects its own ID as the peer_id from a peer, and repeaters
|
||||
expect the master's ID as the peer_id from a server)
|
||||
3. Rewrite the LC word in the DMR payload (see LC rewriting section below).
|
||||
4. Transmit the modified packet to the target system's registered socket address.
|
||||
|
||||
If the source system is a SERVER, the packet goes to each target repeater's socket address
|
||||
on the target server's UDP port. If the source is an OUTBOUND peer, the packet goes to
|
||||
the remote master's address.
|
||||
|
||||
---
|
||||
|
||||
## LC rewriting
|
||||
|
||||
The Link Control (LC) word encodes the call parameters — specifically the TGID, the
|
||||
rf_src, and the call type flag. Every forwarded frame must carry an LC word that matches
|
||||
the target's TGID and the original subscriber's rf_src. If this is not done, radios that
|
||||
join the call after it has started (late entry) will decode the wrong TGID from the
|
||||
embedded LC and may route or display the call incorrectly, or route traffic to the wrong
|
||||
destination.
|
||||
|
||||
LC rewriting is applied to every forwarded frame, not just headers:
|
||||
|
||||
**Voice header frame**: the LC word occupies a known position in the 33-byte DMR payload
|
||||
and is protected by a full rate BPTC 19,96 code. Decode the existing LC, replace the FLCO
|
||||
(Full LC Opcode), rf_src, and dst_id fields with the correct values for this forwarding
|
||||
path, and re-encode with BPTC 19,96.
|
||||
|
||||
**Voice burst frames A-F (dtype_vseq 0-5)**: each voice burst carries an embedded LC
|
||||
fragment and a Golay-protected SYNC pattern. For bursts B through E (dtype_vseq 1 through
|
||||
4), bits 116 through 147 of the 264-bit over-the-air representation carry an embedded LC
|
||||
chunk. These bits are overwritten with the translated LC for this forwarding path.
|
||||
|
||||
**Voice terminator frame**: the LC word is present in the same position as the header and
|
||||
is rewritten identically.
|
||||
|
||||
---
|
||||
|
||||
## Why talker alias is destroyed during bridging
|
||||
|
||||
Talker Alias (TA) is an extended LC feature (FLCO values 0x04 through 0x07) that occupies
|
||||
exactly the same embedded LC slots in voice bursts B through E that the bridge uses to
|
||||
carry the translated call LC. These two uses of the same bits are mutually exclusive.
|
||||
|
||||
If the bridge were to preserve the talker alias embedded LC in bursts B through E, the
|
||||
embedded LC in those frames would describe a different FLCO than the header LC, which
|
||||
carries the standard call LC (FLCO 0x00). A radio performing late entry would read the
|
||||
burst embedded LC, expect a standard call LC, and instead find talker alias data. Depending
|
||||
on the radio's implementation, this results in incorrect TGID display, incorrect routing,
|
||||
or audio artifacts.
|
||||
|
||||
The only consistent behavior is to overwrite the burst embedded LC with the translated call
|
||||
LC on every forwarded frame. This destroys any talker alias that was in those bits.
|
||||
|
||||
This is a deliberate design decision driven by protocol interoperability, not a limitation
|
||||
of the LC handling logic. Preserving talker alias embedded LC in voice bursts B through E
|
||||
is technically possible in a pure HBP-to-HBP bridging path. The reason it is not done is
|
||||
that the bridge must operate correctly across multiple protocols — HBP, IPSC via an adapter
|
||||
such as ipsc2hbp, OpenBridge, and potentially future protocols — each of which carries
|
||||
talker alias through a different mechanism. IPSC, for example, carries TA entirely outside
|
||||
the DMR frame structure. Frames reconstructed by an IPSC-to-HBP adapter carry no valid
|
||||
embedded TA LC. Other future protocol adapters may be similarly constrained.
|
||||
|
||||
Attempting to selectively preserve TA LC only on HBP-to-HBP paths while overwriting it on
|
||||
all other paths introduces conditional logic that must be maintained as new protocol adapters
|
||||
are added. Overwriting with the call LC on every forwarded frame is the safe, uniform, and
|
||||
most interoperable choice across the full range of protocols the bridge may interface with.
|
||||
|
||||
Radios that resolve talker alias from an external database (RadioID.net or a locally loaded
|
||||
DMR ID file) are unaffected because they do not depend on the embedded TA LC at all.
|
||||
|
||||
---
|
||||
|
||||
## Stream end handling
|
||||
|
||||
When a voice terminator frame is received for the current active stream on a system/slot:
|
||||
|
||||
1. Forward the terminator to all routing targets as described above (the terminator must
|
||||
reach target radios so they can cleanly close the call).
|
||||
2. Run the DEACTIVATION check: for each bridge member this system/slot belongs to, check
|
||||
whether dst_id is in the member's OFF list. If it is, apply the deactivation logic from
|
||||
the bridge state machine document.
|
||||
3. If any bridge member's ACTIVE state changed, emit an immediate bridges snapshot.
|
||||
4. Remove the stream_id from the active stream table.
|
||||
|
||||
---
|
||||
|
||||
## Stale stream cleanup
|
||||
|
||||
If a call is interrupted (repeater loses power, network drop, lost terminator) the
|
||||
stream_id may remain in the active stream table indefinitely. A background cleanup pass
|
||||
removes stream entries older than a stale threshold and runs at a periodic interval.
|
||||
|
||||
A DMR voice superframe is approximately 360 milliseconds. A call that ends without a
|
||||
terminator becomes detectable within one or two superframes of the last received voice
|
||||
frame — roughly 1 to 2 seconds. Given the timescales of human PTT interaction, a cleanup
|
||||
threshold of 1 to 2 seconds is fast enough to prevent phantom streams from blocking new
|
||||
calls without requiring any sub-second precision in the cleanup interval. The cleanup pass
|
||||
itself does not need to run faster than every few seconds; it is a backstop for an
|
||||
uncommon failure case, not a timing-critical path.
|
||||
|
||||
This prevents a lost terminator from leaving a phantom active stream that blocks new calls
|
||||
on the same system/slot from being detected as new streams.
|
||||
|
||||
---
|
||||
|
||||
## BRIDGE_BY_SYSTEM index
|
||||
|
||||
The routing loop above requires efficiently finding all bridges a given system/slot/TGID
|
||||
belongs to. Iterating every bridge and every member on every packet is too slow.
|
||||
|
||||
At startup (and on every rules reload), build a secondary index: a mapping from
|
||||
(system_name, slot) to a list of (bridge_pointer, member_pointer) pairs. When a packet
|
||||
arrives, look up (system, slot) in the index to get the candidate bridge members, then
|
||||
check whether dst_id is in each member's TGID. Because TGID lists per member are small,
|
||||
this inner scan is fast.
|
||||
|
||||
This index is rebuilt from scratch on every rules reload. The old index is replaced
|
||||
atomically alongside the bridge table.
|
||||
|
||||
---
|
||||
|
||||
## Unit call routing
|
||||
|
||||
Private (unit) calls between two subscribers are routed differently from group calls. Rather
|
||||
than consulting the bridge table, the router consults a learned subscriber location cache: a
|
||||
table that records which system last heard a given subscriber ID transmit. When a unit call
|
||||
arrives addressed to subscriber X, the router looks up X in the cache and forwards the call
|
||||
to the system where X was last heard.
|
||||
|
||||
The conceptual model is analogous to PIM Sparse Mode for IPv4 multicast: the router does
|
||||
not know where every subscriber is at all times, but it learns subscriber location from
|
||||
observed traffic and uses that information to make a forwarding decision. HBlink4's
|
||||
implementation of this mechanism is the reference for detailed behavior.
|
||||
|
||||
The cache is updated once per call stream, on the voice header frame (the first DMRD packet
|
||||
of a new stream_id). The update is unconditional: the rf_src ID is recorded as having been
|
||||
last seen on the ingress system, and that entry's expiry timestamp is reset to
|
||||
current_time + TIMEOUT. If an entry for that rf_src already exists pointing to a different
|
||||
system, it is overwritten with the new system. This handles subscriber roaming — a unit
|
||||
that moves from system A to system B between calls will have its cache entry updated to
|
||||
system B on its next transmission, regardless of whether the old entry has expired.
|
||||
Updating on subsequent frames of the same call is redundant — a subscriber's location
|
||||
cannot change mid-call — and would add unnecessary work to every packet on the hot path.
|
||||
This requires no explicit configuration; the cache builds itself from observed traffic.
|
||||
|
||||
The cache must be pruned periodically or it will grow without bound as subscribers come and
|
||||
go. A pruning pass runs at a configured interval measured in minutes (not seconds), removing
|
||||
entries whose expiry timestamp has passed. The interval does not need to be fine-grained
|
||||
because subscriber location changes on human timescales, not millisecond timescales. Each
|
||||
time a subscriber is heard, its entry is refreshed, so active subscribers are never pruned.
|
||||
|
||||
When a unit call arrives and the destination subscriber is not in the cache (a cache miss),
|
||||
the call must be forwarded to all systems rather than to a single known destination. This is
|
||||
the equivalent of a PIM Sparse Mode register/flood before the join tree is established. In
|
||||
practice this is a small volume of additional traffic, as cache misses occur only for
|
||||
subscribers not recently heard. Each system has a per-system configuration flag that
|
||||
specifies whether it accepts unit call flooding (forwarding of unit calls destined for
|
||||
unknown subscribers). Systems that do not permit flooding are skipped during a cache miss
|
||||
forward. This allows operators to isolate systems that should only receive unit calls with
|
||||
a confirmed destination.
|
||||
|
||||
The subscriber cache is lossy by design: last-seen wins. It does not handle the case where
|
||||
a subscriber is simultaneously reachable on multiple systems. It is the correct and
|
||||
sufficient mechanism for the common case of routing a private reply back to the originating
|
||||
repeater.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Rules Configuration Design for C Reimplementation
|
||||
|
||||
## Background
|
||||
|
||||
HBLink3's conference bridge rules have been defined in a Python file (`rules.py`) since
|
||||
the original DMRlink `confbridge.py` in 2013. Thirteen years of operator familiarity with
|
||||
this format is a real asset. A C reimplementation of the protocol core should preserve that
|
||||
format completely — not as a compromise, but because it is actually the right tool for what
|
||||
the rules file does.
|
||||
|
||||
## What the rules file actually is
|
||||
|
||||
`rules.py` is not application logic. It is a **data definition file that uses Python as a
|
||||
macro language**. Its only meaningful output is `BRIDGE_CONF` — a dictionary of bridge
|
||||
definitions, each containing a list of system members with their associated parameters.
|
||||
|
||||
The Python feature operators used in practice are straightforward: named lists defined once
|
||||
and referenced in multiple bridge members, variables holding common parameter sets,
|
||||
occasional conditional expressions for environment-specific tweaks. None of this is
|
||||
algorithmic; it is all in service of expressing structured data without repetition.
|
||||
|
||||
This means the rules file is already doing what Python is best suited for: readable,
|
||||
expressive configuration with reuse. The C runtime does not need to understand Python to
|
||||
benefit from it.
|
||||
|
||||
## The separation of concerns
|
||||
|
||||
The architecture draws a clean line between two roles:
|
||||
|
||||
**Configuration authoring** — the operator's domain. Expressed in `rules.py` using Python.
|
||||
Validated by a helper tool before the runtime ever sees it. Can be edited with any text
|
||||
editor, generated by a web UI, or constructed programmatically. The format is stable and
|
||||
human-readable.
|
||||
|
||||
**Protocol runtime** — the C core's domain. Operates entirely on a resolved, validated JSON
|
||||
representation of the rules. Has no Python dependency. Loads the JSON at startup and can
|
||||
reload it at any time without restarting.
|
||||
|
||||
These two roles communicate through a single, well-defined JSON schema. Anything that can
|
||||
produce conforming JSON can author rules — the Python file is one such producer, a web-based
|
||||
admin UI would be another, a script generating rules from a database would be another.
|
||||
|
||||
## The helper tool
|
||||
|
||||
A small Python script (call it `rules_export.py`) bridges the two roles. Its job is:
|
||||
|
||||
1. **Execute** `rules.py` as normal Python. All variable assignments, list references, and
|
||||
any other Python constructs run to completion. By the time execution finishes,
|
||||
`BRIDGE_CONF` is a fully resolved plain data structure with no remaining references or
|
||||
indirection.
|
||||
|
||||
2. **Validate** the resolved structure against the expected schema. Check that required
|
||||
fields are present, that types are correct, that system names referenced in rules
|
||||
correspond to known systems, and so on. Emit clear, actionable error messages if
|
||||
anything is wrong. This is where the "missing trailing comma" class of errors gets
|
||||
caught, with a Python traceback that points directly at the offending line.
|
||||
|
||||
3. **Emit** the validated structure as JSON to stdout (or to a file). The output contains
|
||||
only concrete values — no variable names, no Python expressions, no references. It is
|
||||
the fully evaluated result.
|
||||
|
||||
The helper is intentionally a separate tool rather than a startup step inside the C binary.
|
||||
This means an operator can run it manually to validate a rules change before applying it,
|
||||
a deployment script can run it as a preflight check, and a web-based admin UI can invoke
|
||||
it server-side to validate rules before saving them.
|
||||
|
||||
## How the C core loads rules
|
||||
|
||||
At startup, the C core reads the JSON rules file from a configured path. It parses the JSON
|
||||
into its internal bridge table — an array of bridge structs, each containing an array of
|
||||
member structs with their parameters. This is the only representation the runtime uses.
|
||||
The JSON file is not kept open; it is read once, parsed, and the file handle is closed.
|
||||
|
||||
The JSON schema is documented separately and is the stable interface between configuration
|
||||
tooling and the runtime. The Python rules file format is one representation that maps to
|
||||
this schema; it is not the schema itself.
|
||||
|
||||
## Live reload without restart
|
||||
|
||||
When an operator modifies `rules.py`, the workflow is:
|
||||
|
||||
1. Run the helper tool to validate and export the new JSON. If validation fails, no change
|
||||
is applied; the running daemon continues on the existing rules.
|
||||
|
||||
2. Signal the running daemon (SIGHUP is the Unix convention for configuration reload).
|
||||
|
||||
3. The daemon, at the top of its next event loop iteration, detects the signal flag and
|
||||
reads the new JSON file. It constructs a new bridge table in fresh memory alongside the
|
||||
existing one.
|
||||
|
||||
4. If the new table is valid, the daemon atomically replaces the pointer to the active
|
||||
bridge table with the pointer to the new one, then frees the old table. From this point
|
||||
forward all routing decisions use the new rules.
|
||||
|
||||
5. If the new table is invalid (malformed JSON, missing fields, etc.), the daemon logs the
|
||||
error and continues running on the existing rules. No traffic is disrupted.
|
||||
|
||||
Any call in progress at the moment of the swap completes under the old rules. The swap
|
||||
itself is instantaneous from the event loop's perspective.
|
||||
|
||||
## Why the single-threaded model makes this safe
|
||||
|
||||
In a multi-threaded runtime, swapping the bridge table under live traffic requires locks,
|
||||
reference counting, or other synchronization to ensure no thread is mid-read on the old
|
||||
table when it is freed. This is non-trivial to get right.
|
||||
|
||||
In a single-threaded event loop model, the swap happens at a well-defined point — the top
|
||||
of a loop iteration, before any packet dispatch — at which moment by definition nothing
|
||||
else is touching the bridge table. There are no concurrent readers. The correctness argument
|
||||
is trivial: the old pointer is live until the new one replaces it, and exactly one thing
|
||||
holds the pointer at any time.
|
||||
|
||||
The brief pause while the JSON file is read and parsed is acceptable for this use case.
|
||||
Amateur radio PTT traffic is tolerant of a reload pause measured in tens of milliseconds.
|
||||
If future requirements demanded zero-pause reloads, the parsing work could be moved to a
|
||||
background thread that hands the completed table back to the event loop via a pipe, keeping
|
||||
the actual pointer swap on the main thread — but this is an optimization, not a requirement
|
||||
for correctness.
|
||||
|
||||
## What this enables beyond the rules file
|
||||
|
||||
Once the JSON schema is the stable interface, multiple configuration front-ends become
|
||||
possible independently of one another:
|
||||
|
||||
- The existing `rules.py` Python format, unchanged, for operators familiar with it
|
||||
- A web-based admin UI with authentication that constructs and validates the JSON directly,
|
||||
with no Python file involved
|
||||
- A REST endpoint on the admin service that accepts JSON rules and triggers a reload,
|
||||
enabling external orchestration systems to manage bridge configuration
|
||||
- Automated rule generation from a database, a planning tool, or a coordination system
|
||||
|
||||
None of these require changes to the C runtime. The runtime speaks JSON; whatever produces
|
||||
that JSON is an independent concern.
|
||||
|
||||
This is the natural endpoint of what the Python rules file was always doing — expressing
|
||||
structured data in a readable, reusable way. The JSON schema makes that structure explicit
|
||||
and opens it to any tooling that can speak it.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Telemetry Event Schema for C Reimplementation
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The reporting feed is a secondary concern. The C core is a DMR call router first. The
|
||||
telemetry emitter must never slow, stall, or complicate the packet routing path. Concretely:
|
||||
|
||||
- All TCP writes to reporting clients are non-blocking. If a client's socket buffer is full,
|
||||
the write is dropped for that client. The routing path does not wait.
|
||||
- Stream events (call START/END) are emitted inline in the routing path because they are
|
||||
time-sensitive, but they must be cheap — a single formatted write to each client fd.
|
||||
- Config and bridges events are periodic snapshots, not live state. They are assembled and
|
||||
sent on a timer, not on every state change. Exception: bridge ACTIVE state changes may
|
||||
trigger an immediate bridges snapshot because operators observe bridge state directly and
|
||||
a 10-second lag is confusing on a PTT system.
|
||||
- If a reporting client disconnects or is slow, it is removed from the client list and
|
||||
forgotten. It does not affect other clients or the routing core.
|
||||
|
||||
A C implementer should treat the reporting feed as a UDP-style "best effort" service that
|
||||
happens to run over TCP for reliability with slow consumers. The core routes calls regardless
|
||||
of whether any clients are connected.
|
||||
|
||||
---
|
||||
|
||||
## Transport
|
||||
|
||||
The core listens on a configured TCP port (default 4321). Dashboard and admin clients
|
||||
connect and receive a stream of newline-delimited JSON objects (NDJSON) — one complete JSON
|
||||
object per line, terminated by `\n`. Clients are read-only; the core ignores any data sent
|
||||
by clients.
|
||||
|
||||
On client connect, the core immediately sends the current `config` event and the current
|
||||
`bridges` event so the client has a complete picture before any deltas arrive.
|
||||
|
||||
---
|
||||
|
||||
## Event Types
|
||||
|
||||
### `config` — System snapshot
|
||||
|
||||
Sent periodically (every `REPORT_INTERVAL` seconds, typically 10). Contains the full state
|
||||
of all configured systems and their connected repeaters/peers. This is a complete snapshot,
|
||||
not a delta — receivers replace their entire system state on each `config` event.
|
||||
|
||||
```
|
||||
{
|
||||
"type": "config",
|
||||
"ping_time": <integer, seconds, from GLOBAL config>,
|
||||
"max_missed": <integer, from GLOBAL config>,
|
||||
"systems": {
|
||||
"<SYSTEM_NAME>": <system object, see below>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**System object — SERVER mode** (HBP master, repeaters connect to it):
|
||||
```
|
||||
{
|
||||
"MODE": "SERVER",
|
||||
"REPEAT": <boolean>,
|
||||
"CALLSIGN": <string>,
|
||||
"RADIO_ID": <integer>,
|
||||
"SLOTS": <integer, bitmask>,
|
||||
"LOCATION": <string>,
|
||||
"REPEATERS": {
|
||||
"<peer_id_hex>": {
|
||||
"CALLSIGN": <string>,
|
||||
"RADIO_ID": <integer>,
|
||||
"IP": <string>,
|
||||
"PORT": <integer>,
|
||||
"RX_FREQ": <string, 9-digit>,
|
||||
"TX_FREQ": <string, 9-digit>,
|
||||
"SLOTS": <integer>,
|
||||
"CONNECTION": <string, "YES" | "NO" | handshake state>,
|
||||
"CONNECTED": <float, Unix epoch of connection time>,
|
||||
"LAST_PING": <float, Unix epoch of last RPTP received from repeater>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**System object — OUTBOUND mode** (HBP peer, connects to a remote master):
|
||||
```
|
||||
{
|
||||
"MODE": "OUTBOUND",
|
||||
"CALLSIGN": <string>,
|
||||
"RADIO_ID": <integer>,
|
||||
"SLOTS": <integer>,
|
||||
"LOCATION": <string>,
|
||||
"SERVER_IP": <string>,
|
||||
"SERVER_PORT": <integer>,
|
||||
"STATS": {
|
||||
"CONNECTION": <string, "YES" | "NO" | handshake state>,
|
||||
"CONNECTED": <float, Unix epoch, null if not connected>,
|
||||
"NUM_OUTSTANDING": <integer, unacknowledged pings, 0 in normal operation>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**System object — OPENBRIDGE mode**:
|
||||
```
|
||||
{
|
||||
"MODE": "OPENBRIDGE",
|
||||
"CALLSIGN": <string>,
|
||||
"RADIO_ID": <integer>,
|
||||
"SLOTS": <integer>,
|
||||
"STATS": {
|
||||
"CONNECTION": <string>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes on epoch fields**: `CONNECTED` and `LAST_PING` are Unix epoch floats (seconds since
|
||||
1970-01-01 UTC) as returned by C's `time()` or Python's `time.time()`. Dashboard consumers
|
||||
subtract from the current wall clock to get elapsed seconds. The dashboard server enriches
|
||||
these to `connected_secs` and `last_ping_secs` before forwarding to browser clients, so
|
||||
browser code never sees raw epochs.
|
||||
|
||||
---
|
||||
|
||||
### `bridges` — Bridge state snapshot
|
||||
|
||||
Sent periodically (every `REPORT_INTERVAL`) and immediately when any bridge member's ACTIVE
|
||||
state changes. Contains the full state of all configured bridges. Receivers replace their
|
||||
entire bridge state on each `bridges` event.
|
||||
|
||||
```
|
||||
{
|
||||
"type": "bridges",
|
||||
"bridges": {
|
||||
"<BRIDGE_NAME>": [
|
||||
{
|
||||
"SYSTEM": <string, system name>,
|
||||
"TGID": <integer>,
|
||||
"TS": <integer, 1 or 2>,
|
||||
"ACTIVE": <boolean>,
|
||||
"TO_TYPE": <string, "ON" | "OFF" | "NONE">,
|
||||
"TIMEOUT": <integer, seconds>,
|
||||
"TIMER": <float, Unix epoch when this member expires, 0 if TO_TYPE is NONE>,
|
||||
"ON": [<integer TGID>, ...],
|
||||
"OFF": [<integer TGID>, ...],
|
||||
"RESET": [<integer TGID>, ...]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`TIMER` is an absolute epoch. Consumers compute `remaining = TIMER - now` to display a
|
||||
countdown. When `remaining <= 0` the member has expired (ACTIVE will become False on the
|
||||
next rule timer evaluation). Dashboard consumers must handle the case where `TIMER` is in
|
||||
the past before the next `bridges` snapshot arrives.
|
||||
|
||||
---
|
||||
|
||||
### `stream` — Call event
|
||||
|
||||
Emitted once at call START (first packet of a new stream) and once at call END (voice
|
||||
terminator received, or stream reaped as stale). These are the only events emitted in the
|
||||
routing hot path and must be assembled and written with minimal overhead.
|
||||
|
||||
```
|
||||
{
|
||||
"type": "stream",
|
||||
"action": "START" | "END",
|
||||
"system": <string, ingress system name>,
|
||||
"slot": <integer, 1 or 2>,
|
||||
"stream_id": <string, 4-byte stream ID as hex>,
|
||||
"src": <integer, rf_src subscriber radio ID>,
|
||||
"dst": <integer, dst_id TGID for group calls, subscriber ID for unit calls>,
|
||||
"peer": <integer, repeater radio ID that originated the call>,
|
||||
"trx": "RX" | "TX",
|
||||
"call_type": "GROUP VOICE" | "UNIT VOICE",
|
||||
"pkt_time": <float, Unix epoch of the packet that triggered this event>
|
||||
}
|
||||
```
|
||||
|
||||
`trx` is `RX` for the ingress leg (received from a repeater or peer) and `TX` for egress
|
||||
legs (forwarded to target systems). Dashboard call logs typically show only `RX` legs to
|
||||
avoid duplicating each call for every forwarded copy.
|
||||
|
||||
---
|
||||
|
||||
### `hblink` — Feed connectivity
|
||||
|
||||
Emitted by the **dashboard server** (Python), not by the C core itself, to notify browser
|
||||
clients that the connection to the C core's reporting port was lost or restored. The C core
|
||||
does not emit this event type. It is included here for completeness so dashboard implementers
|
||||
understand the full event space.
|
||||
|
||||
```
|
||||
{ "type": "hblink", "connected": <boolean> }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What is deliberately excluded
|
||||
|
||||
The following are not emitted because they would add overhead to the hot path or are
|
||||
derivable by consumers from the data above:
|
||||
|
||||
- Per-packet statistics (byte counts, packet counts beyond what is in STATS)
|
||||
- Talker alias data (destroyed during LC rewriting; see routing flow document)
|
||||
- Per-system call history (consumers build this from stream events)
|
||||
- Internal timing metrics (ping round-trip times, etc.)
|
||||
|
||||
The schema is intentionally minimal. Adding fields is cheap; removing them once consumers
|
||||
depend on them is expensive. When in doubt, leave it out.
|
||||
+57
-128
@@ -1,45 +1,24 @@
|
||||
# PROGRAM-WIDE PARAMETERS GO HERE
|
||||
# PATH - working path for files, leave it alone unless you NEED to change it
|
||||
# PING_TIME - the interval that peers will ping the master, and re-try registraion
|
||||
# - how often the Master maintenance loop runs
|
||||
# MAX_MISSED - how many pings are missed before we give up and re-register
|
||||
# - number of times the master maintenance loop runs before de-registering a peer
|
||||
# HBlink3 main configuration. Full field reference: CONFIGURING.md ACL guide: ACLS.md
|
||||
#
|
||||
# ACLs:
|
||||
# NOTE: every [SECTION] name below is referenced by rules.py. If a name in rules.py has
|
||||
# no matching system here, bridge.py will NOT start. Keep the two files in sync.
|
||||
#
|
||||
# Access Control Lists are a very powerful tool for administering your system.
|
||||
# But they consume packet processing time. Disable them if you are not using them.
|
||||
# But be aware that, as of now, the configuration stanzas still need the ACL
|
||||
# sections configured even if you're not using them.
|
||||
#
|
||||
# REGISTRATION ACLS ARE ALWAYS USED, ONLY SUBSCRIBER AND TGID MAY BE DISABLED!!!
|
||||
#
|
||||
# The 'action' May be PERMIT|DENY
|
||||
# Each entry may be a single radio id, or a hypenated range (e.g. 1-2999)
|
||||
# Format:
|
||||
# ACL = 'action:id|start-end|,id|start-end,....'
|
||||
# --for example--
|
||||
# SUB_ACL: DENY:1,1000-2000,4500-60000,17
|
||||
#
|
||||
# ACL Types:
|
||||
# REG_ACL: peer radio IDs for registration (only used on HBP master systems)
|
||||
# SUB_ACL: subscriber IDs for end-users
|
||||
# TGID_TS1_ACL: destination talkgroup IDs on Timeslot 1
|
||||
# TGID_TS2_ACL: destination talkgroup IDs on Timeslot 2
|
||||
#
|
||||
# ACLs may be repeated for individual systems if needed for granularity
|
||||
# Global ACLs will be processed BEFORE the system level ACLs
|
||||
# Packets will be matched against all ACLs, GLOBAL first. If a packet 'passes'
|
||||
# All elements, processing continues. Packets are discarded at the first
|
||||
# negative match, or 'reject' from an ACL element.
|
||||
#
|
||||
# If you do not wish to use ACLs, set them to 'PERMIT:ALL'
|
||||
# TGID_TS1_ACL in the global stanza is used for OPENBRIDGE systems, since all
|
||||
# traffic is passed as TS 1 between OpenBridges
|
||||
# [GLOBAL] — program-wide timers plus the global ACLs (run before per-system ACLs;
|
||||
# both must pass). ACL format: ACTION:list, e.g. PERMIT:ALL, DENY:1, DENY:1-2000,17.
|
||||
# Leave PATH alone unless you must change it.
|
||||
[GLOBAL]
|
||||
PATH: ./
|
||||
PING_TIME: 5
|
||||
MAX_MISSED: 3
|
||||
# Chronic connectivity monitoring (optional; these defaults apply if omitted).
|
||||
# The dashboard reports each repeater's ping loss -- the percentage of its
|
||||
# expected keepalive pings that went missing -- over the last PING_LOSS_WINDOW
|
||||
# minutes (1-60; larger = smoother/longer memory), and turns a repeater's
|
||||
# callsign gold at or above PING_LOSS_WARN percent. This catches a repeater that
|
||||
# stays connected but has a lossy link (choppy/dropped audio). 0 disables the
|
||||
# gold flag. A repeater that simply pings less often is not counted as lossy.
|
||||
PING_LOSS_WINDOW: 5
|
||||
PING_LOSS_WARN: 5
|
||||
USE_ACL: True
|
||||
REG_ACL: PERMIT:ALL
|
||||
SUB_ACL: DENY:1
|
||||
@@ -47,57 +26,36 @@ TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
|
||||
# NOT YET WORKING: NETWORK REPORTING CONFIGURATION
|
||||
# Enabling "REPORT" will configure a socket-based reporting
|
||||
# system that will send the configuration and other items
|
||||
# to a another process (local or remote) that may process
|
||||
# the information for some useful purpose, like a web dashboard.
|
||||
#
|
||||
# REPORT - True to enable, False to disable
|
||||
# REPORT_INTERVAL - Seconds between reports
|
||||
# REPORT_PORT - TCP port to listen on if "REPORT_NETWORKS" = NETWORK
|
||||
# REPORT_CLIENTS - comma separated list of IPs you will allow clients
|
||||
# to connect on. Entering a * will allow all.
|
||||
#
|
||||
# ****FOR NOW MUST BE TRUE - USE THE LOOPBACK IF YOU DON'T USE THIS!!!****
|
||||
# [REPORTS] — newline-delimited JSON event/stat feed for the dashboard (dashboard/).
|
||||
# Leave REPORT True; set REPORT_PORT to match the dashboard's HBLINK_PORT and point
|
||||
# REPORT_CLIENTS at the dashboard host (comma-separated IPs, or a single * for all).
|
||||
# Full reference: CONFIGURING.md.
|
||||
[REPORTS]
|
||||
REPORT: True
|
||||
REPORT_INTERVAL: 60
|
||||
REPORT_INTERVAL: 10
|
||||
REPORT_PORT: 4321
|
||||
REPORT_CLIENTS: 127.0.0.1
|
||||
|
||||
|
||||
# SYSTEM LOGGER CONFIGURAITON
|
||||
# This allows the logger to be configured without chaning the individual
|
||||
# python logger stuff. LOG_FILE should be a complete path/filename for *your*
|
||||
# system -- use /dev/null for non-file handlers.
|
||||
# LOG_HANDLERS may be any of the following, please, no spaces in the
|
||||
# list if you use several:
|
||||
# null
|
||||
# console
|
||||
# console-timed
|
||||
# file
|
||||
# file-timed
|
||||
# syslog
|
||||
# LOG_LEVEL may be any of the standard syslog logging levels, though
|
||||
# as of now, DEBUG, INFO, WARNING and CRITICAL are the only ones
|
||||
# used.
|
||||
#
|
||||
# [LOGGER] — LOG_HANDLERS: null|console|console-timed|file|file-timed|syslog
|
||||
# (comma-join, no spaces). LOG_LEVEL: DEBUG|INFO|WARNING|CRITICAL. Under systemd,
|
||||
# set LOG_FILE: /dev/null with a console handler and just rely on the journal.
|
||||
# Full reference: CONFIGURING.md.
|
||||
[LOGGER]
|
||||
LOG_FILE: /tmp/hblink.log
|
||||
LOG_HANDLERS: console-timed
|
||||
LOG_LEVEL: DEBUG
|
||||
LOG_NAME: HBlink
|
||||
|
||||
# DOWNLOAD AND IMPORT SUBSCRIBER, PEER and TGID ALIASES
|
||||
# Ok, not the TGID, there's no master list I know of to download
|
||||
# This is intended as a facility for other applcations built on top of
|
||||
# HBlink to use, and will NOT be used in HBlink directly.
|
||||
# STALE_DAYS is the number of days since the last download before we
|
||||
# download again. Don't be an ass and change this to less than a few days.
|
||||
# [ALIASES] — DMR-ID -> callsign lookups. The DASHBOARD owns these files: set
|
||||
# TRY_DOWNLOAD: False here and point PATH at the dashboard's directory. Loading the full
|
||||
# subscriber DB here costs ~500 MB RAM per process (>1 GB if the dashboard loads it too),
|
||||
# so keep USE_ALIASES: False unless the DB has been filtered small (dashboard
|
||||
# FILTER_COUNTRIES). Full reference incl. the memory warning: CONFIGURING.md.
|
||||
[ALIASES]
|
||||
TRY_DOWNLOAD: True
|
||||
PATH: ./
|
||||
USE_ALIASES: False
|
||||
TRY_DOWNLOAD: False
|
||||
PATH: dashboard/
|
||||
PEER_FILE: peer_ids.json
|
||||
SUBSCRIBER_FILE: subscriber_ids.json
|
||||
TGID_FILE: talkgroup_ids.json
|
||||
@@ -105,25 +63,13 @@ PEER_URL: https://www.radioid.net/static/rptrs.json
|
||||
SUBSCRIBER_URL: https://www.radioid.net/static/users.json
|
||||
STALE_DAYS: 7
|
||||
|
||||
# OPENBRIDGE INSTANCES - DUPLICATE SECTION FOR MULTIPLE CONNECTIONS
|
||||
# OpenBridge is a protocol originall created by DMR+ for connection between an
|
||||
# IPSC2 server and Brandmeister. It has been implemented here at the suggestion
|
||||
# of the Brandmeister team as a way to legitimately connect HBlink to the
|
||||
# Brandemiester network.
|
||||
# It is recommended to name the system the ID of the Brandmeister server that
|
||||
# it connects to, but is not necessary. TARGET_IP and TARGET_PORT are of the
|
||||
# Brandmeister or IPSC2 server you are connecting to. PASSPHRASE is the password
|
||||
# that must be agreed upon between you and the operator of the server you are
|
||||
# connecting to. NETWORK_ID is a number in the format of a DMR Radio ID that
|
||||
# will be sent to the other server to identify this connection.
|
||||
# other parameters follow the other system types.
|
||||
#
|
||||
# ACLs:
|
||||
# OpenBridge does not 'register', so registration ACL is meaningless.
|
||||
# Proper OpenBridge passes all traffic on TS1.
|
||||
# HBlink can extend OPB to use both slots for unit calls only.
|
||||
# Setting "BOTH_SLOTS" True ONLY affects unit traffic!
|
||||
# Otherwise ACLs work as described in the global stanza
|
||||
# [OPENBRIDGE] — Server-to-Server link (e.g. BrandMeister, IPSC2). Duplicate for more.
|
||||
# TARGET_IP/PORT = far server; PASSPHRASE = shared HMAC key (must match both ends);
|
||||
# NETWORK_ID identifies this server. All traffic is TS1 (no registration).
|
||||
# BOTH_SLOTS: True is HBlink-to-HBlink ONLY -- no other server is known to accept it.
|
||||
# PRESERVE_SOURCE_PEER: True forwards the originating peer ID instead of NETWORK_ID
|
||||
# (unvalidated field; improves call provenance; best when both ends enable it).
|
||||
# Full reference: CONFIGURING.md.
|
||||
[OBP-1]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: True
|
||||
@@ -134,29 +80,20 @@ PASSPHRASE: password
|
||||
TARGET_IP: 1.2.3.4
|
||||
TARGET_PORT: 62035
|
||||
BOTH_SLOTS: True
|
||||
PRESERVE_SOURCE_PEER: False
|
||||
USE_ACL: True
|
||||
SUB_ACL: DENY:1
|
||||
TGID_ACL: PERMIT:ALL
|
||||
|
||||
# MASTER INSTANCES - DUPLICATE SECTION FOR MULTIPLE MASTERS
|
||||
# HomeBrew Protocol Master instances go here.
|
||||
# IP may NOT be left blank. To listen on all interfaces use 0.0.0.0 or ::
|
||||
# Port should be the port you want this master to listen on. It must be unique
|
||||
# and unused by anything else.
|
||||
# Repeat - if True, the master repeats traffic to peers, False, it does nothing.
|
||||
#
|
||||
# MAX_PEERS -- maximun number of peers that may be connect to this master
|
||||
# at any given time. This is very handy if you're allowing hotspots to
|
||||
# connect, or using a limited computer like a Raspberry Pi.
|
||||
#
|
||||
# ACLs:
|
||||
# See comments in the GLOBAL stanza
|
||||
[MASTER-1]
|
||||
MODE: MASTER
|
||||
# [SERVER] — you are the Server; Clients (repeaters/hotspots) connect in. Duplicate for
|
||||
# more. IP may NOT be blank (0.0.0.0 or :: = all interfaces); PORT must be unique/unused.
|
||||
# REPEAT: True repeats between connected Clients; MAX_REPEATERS caps connections.
|
||||
# Full reference: CONFIGURING.md.
|
||||
[SERVER-1]
|
||||
MODE: SERVER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_PEERS: 10
|
||||
EXPORT_AMBE: False
|
||||
MAX_REPEATERS: 10
|
||||
IP: 0.0.0.0
|
||||
PORT: 54000
|
||||
PASSPHRASE: s3cr37w0rd
|
||||
@@ -167,27 +104,19 @@ SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
# PEER INSTANCES - DUPLICATE SECTION FOR MULTIPLE PEERS
|
||||
# There are a LOT of errors in the HB Protocol specifications on this one!
|
||||
# MOST of these items are just strings and will be properly dealt with by the program
|
||||
# The TX & RX Frequencies are 9-digit numbers, and are the frequency in Hz.
|
||||
# Latitude is an 8-digit unsigned floating point number.
|
||||
# Longitude is a 9-digit signed floating point number.
|
||||
# Height is in meters
|
||||
# Setting Loose to True relaxes the validation on packets received from the master.
|
||||
# This will allow HBlink to connect to a non-compliant system such as XLXD, DMR+ etc.
|
||||
#
|
||||
# ACLs:
|
||||
# See comments in the GLOBAL stanza
|
||||
[REPEATER-1]
|
||||
MODE: PEER
|
||||
# [OUTBOUND] — you are a Client: HBlink masquerades as a repeater/hotspot and dials out
|
||||
# to a Server. Duplicate for more. RX/TX_FREQ in Hz; LAT/LONG in decimal degrees; HEIGHT
|
||||
# in meters. LOOSE: True relaxes validation for non-compliant Servers (XLXD, DMR+).
|
||||
# NOTE: BrandMeister does NOT accept OUTBOUND/Client connections -- it requires OpenBridge.
|
||||
# Full reference: CONFIGURING.md.
|
||||
[OUTBOUND-1]
|
||||
MODE: OUTBOUND
|
||||
ENABLED: True
|
||||
LOOSE: False
|
||||
EXPORT_AMBE: False
|
||||
IP: 0.0.0.0
|
||||
PORT: 54001
|
||||
MASTER_IP: 172.16.1.1
|
||||
MASTER_PORT: 54000
|
||||
SERVER_IP: 172.16.1.1
|
||||
SERVER_PORT: 54000
|
||||
PASSPHRASE: homebrew
|
||||
CALLSIGN: W1ABC
|
||||
RADIO_ID: 312000
|
||||
@@ -209,4 +138,4 @@ OPTIONS:
|
||||
USE_ACL: True
|
||||
SUB_ACL: DENY:1
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
@@ -0,0 +1,35 @@
|
||||
# systemd unit for the HBlink3 dashboard (dashboard/server.py).
|
||||
#
|
||||
# Install:
|
||||
# sudo cp hblink3-dash.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now hblink3-dash
|
||||
#
|
||||
# Adjust User/Group and the paths below to your install. The dashboard reads
|
||||
# dashboard/config.py for its settings (copy it from dashboard/config_sample.py).
|
||||
|
||||
[Unit]
|
||||
Description=HBlink3 Dashboard
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=cort
|
||||
Group=cort
|
||||
WorkingDirectory=/home/cort/hblink3/dashboard
|
||||
ExecStart=/home/cort/hblink3/venv/bin/python /home/cort/hblink3/dashboard/server.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Logging to the journal (journalctl -u hblink3-dash)
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hblink3-dash
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,37 @@
|
||||
# systemd unit for the HBlink3 conference-bridge router (bridge.py).
|
||||
#
|
||||
# Install:
|
||||
# sudo cp hblink3.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now hblink3
|
||||
#
|
||||
# Adjust User/Group and the paths below to your install. bridge.py defaults to
|
||||
# hblink.cfg and rules.py in its own directory; pass -c/-r in ExecStart to use
|
||||
# other files. To run a different app (e.g. the plain proxy), point ExecStart at
|
||||
# bridge_all.py or hblink.py instead.
|
||||
|
||||
[Unit]
|
||||
Description=HBlink3 DMR Conference Bridge Router
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=cort
|
||||
Group=cort
|
||||
WorkingDirectory=/home/cort/hblink3
|
||||
ExecStart=/home/cort/hblink3/venv/bin/python /home/cort/hblink3/bridge.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
# Logging to the journal (journalctl -u hblink3)
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=hblink3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -18,6 +18,14 @@
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
# ============================================================================
|
||||
# LEGACY -- NOT MIGRATED TO ASYNCIO.
|
||||
# The core (hblink.py) moved from Twisted to asyncio in the 2026 cleanup. This
|
||||
# utility still uses Twisted (reactor) and subclasses the now-asyncio HBSYSTEM,
|
||||
# so it will NOT run against the current core without being migrated. It is kept
|
||||
# for reference; install Twisted separately if you intend to revive/port it.
|
||||
# ============================================================================
|
||||
|
||||
# Python modules we need
|
||||
import sys
|
||||
from bitarray import bitarray
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
###############################################################################
|
||||
|
||||
# ============================================================================
|
||||
# LEGACY -- NOT MIGRATED TO ASYNCIO.
|
||||
# The core (hblink.py) moved from Twisted to asyncio in the 2026 cleanup. This
|
||||
# utility still uses Twisted (reactor) and subclasses the now-asyncio HBSYSTEM,
|
||||
# so it will NOT run against the current core without being migrated. It is kept
|
||||
# for reference; install Twisted separately if you intend to revive/port it.
|
||||
# ============================================================================
|
||||
|
||||
# Python modules we need
|
||||
import sys
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Packaging / project metadata for HBlink3.
|
||||
#
|
||||
# HBlink3 is normally run from a clone (e.g. `python bridge.py -c hblink.cfg`),
|
||||
# not pip-installed, but keeping dependency and Python-version metadata in the
|
||||
# standard location lets tooling (pip, uv, IDEs) discover it correctly.
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hblink3"
|
||||
version = "1.0.0"
|
||||
description = "Open-source HomeBrew Repeater Protocol (HBP) client/master and conference-bridge router for DMR"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0-or-later" }
|
||||
authors = [
|
||||
{ name = "Cortney T. Buffington, N0MJS", email = "n0mjs@me.com" },
|
||||
]
|
||||
keywords = ["dmr", "hbp", "homebrew", "repeater", "amateur-radio", "mmdvm"]
|
||||
|
||||
# The networking core is stdlib asyncio (asyncio.run / create_datagram_endpoint /
|
||||
# create_task / Event), all available on 3.8+. A future bump to >=3.11 would let
|
||||
# the maintenance/rule-timer loops use asyncio.TaskGroup / asyncio.timeout, but
|
||||
# nothing requires it today.
|
||||
requires-python = ">=3.8"
|
||||
|
||||
dependencies = [
|
||||
"bitarray>=2.0.0",
|
||||
"dmr_utils3>=0.1.30",
|
||||
]
|
||||
|
||||
# Optional extras. The dashboard (dashboard/) is a separate program but can share
|
||||
# the core's virtualenv: pip install -e ".[dashboard]"
|
||||
[project.optional-dependencies]
|
||||
dashboard = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.27",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/n0mjs710/hblink3"
|
||||
|
||||
# The core routing modules. Listed explicitly because they are top-level modules
|
||||
# rather than a package, so a `pip install .` resolves them correctly.
|
||||
[tool.setuptools]
|
||||
py-modules = ["hblink", "bridge", "bridge_all", "config", "const", "log", "reporting_const"]
|
||||
+15
-5
@@ -1,5 +1,15 @@
|
||||
bitstring>=3.1.5
|
||||
bitarray>=0.8.1
|
||||
Twisted>=16.3.0
|
||||
dmr_utils3>=0.1.19
|
||||
configparser>=3.0.0
|
||||
# Core runtime dependencies for the HBlink3 routing applications
|
||||
# (hblink.py, bridge.py, bridge_all.py).
|
||||
#
|
||||
# The networking core uses Python's stdlib asyncio -- no Twisted required.
|
||||
#
|
||||
# Removed in the 2026 cleanup:
|
||||
# configparser -- part of the Python 3 standard library; the pin was a no-op
|
||||
# bitstring -- only used by the optional voice utilities (mk_voice.py)
|
||||
# Twisted -- replaced by asyncio; only the legacy, unmigrated voice
|
||||
# utilities (playback.py, play_ambe.py) still import it
|
||||
#
|
||||
# Floors are conservative; the versions these were last tested against are noted.
|
||||
bitarray>=2.0.0 # tested: 3.8.1
|
||||
dmr_utils3>=0.1.31 # tested: 0.1.31
|
||||
ijson>=3.2 # streaming JSON parse for filtered subscriber downloads
|
||||
+47
-33
@@ -1,54 +1,67 @@
|
||||
'''
|
||||
THIS EXAMPLE WILL NOT WORK AS IT IS - YOU MUST SPECIFY YOUR OWN VALUES!!!
|
||||
THIS EXAMPLE WILL NOT WORK AS-IS - YOU MUST SPECIFY YOUR OWN VALUES!
|
||||
Full reference: CONFIGURING.md (rules.py section).
|
||||
|
||||
This file is organized around the "Conference Bridges" that you wish to use. If you're a c-Bridge
|
||||
person, think of these as "bridge groups". You might also liken them to a "reflector". If a particular
|
||||
system is "ACTIVE" on a particular conference bridge, any traffid from that system will be sent
|
||||
to any other system that is active on the bridge as well. This is not an "end to end" method, because
|
||||
each system must independently be activated on the bridge.
|
||||
BRIDGES defines "conference bridges" (think bridge groups / reflectors): every system
|
||||
marked ACTIVE on a bridge exchanges traffic with every other ACTIVE system on that same
|
||||
bridge. Not end-to-end -- each system is activated on each bridge independently.
|
||||
|
||||
The first level (e.g. "WORLDWIDE" or "STATEWIDE" in the examples) is the name of the conference
|
||||
bridge. This is any arbitrary ASCII text string you want to use. Under each conference bridge
|
||||
definition are the following items -- one line for each HBSystem as defined in the main HBlink
|
||||
configuration file.
|
||||
|
||||
* SYSTEM - The name of the sytem as listed in the main hblink configuration file (e.g. hblink.cfg)
|
||||
This MUST be the exact same name as in the main config file!!!
|
||||
* TS - Timeslot used for matching traffic to this confernce bridge
|
||||
XLX connections should *ALWAYS* use TS 2 only.
|
||||
* TGID - Talkgroup ID used for matching traffic to this conference bridge
|
||||
XLX connections should *ALWAYS* use TG 9 only.
|
||||
* ON and OFF are LISTS of Talkgroup IDs used to trigger this system off and on. Even if you
|
||||
only want one (as shown in the ON example), it has to be in list format. None can be
|
||||
handled with an empty list, such as " 'ON': [] ".
|
||||
* TO_TYPE is timeout type. If you want to use timers, ON means when it's turned on, it will
|
||||
turn off afer the timout period and OFF means it will turn back on after the timout
|
||||
period. If you don't want to use timers, set it to anything else, but 'NONE' might be
|
||||
a good value for documentation!
|
||||
* TIMOUT is a value in minutes for the timout timer. No, I won't make it 'seconds', so don't
|
||||
ask. Timers are performance "expense".
|
||||
* RESET is a list of Talkgroup IDs that, in addition to the ON and OFF lists will cause a running
|
||||
timer to be reset. This is useful if you are using different TGIDs for voice traffic than
|
||||
triggering. If you are not, there is NO NEED to use this feature.
|
||||
Each bridge key is an arbitrary name; its value is one entry per participating system:
|
||||
SYSTEM - system name from hblink.cfg -- MUST match EXACTLY, or bridge.py won't start
|
||||
TS - timeslot 1 or 2 to match (XLX: always TS 2)
|
||||
TGID - talkgroup ID to match (XLX: always TG 9)
|
||||
ACTIVE - True/False: passing traffic now? (ON/OFF triggers below can flip it)
|
||||
TO_TYPE - 'ON' = auto-off after TIMEOUT; 'OFF' = auto-on after TIMEOUT; else 'NONE'
|
||||
TIMEOUT - timer length in MINUTES (minutes only; timers cost performance)
|
||||
ON/OFF - lists of TGIDs that activate/deactivate this system (always lists; [] = none)
|
||||
RESET - list of TGIDs that reset a running timer (only when voice TGID != trigger TGIDs)
|
||||
'''
|
||||
|
||||
BRIDGES = {
|
||||
'WORLDWIDE': [
|
||||
{'SYSTEM': 'MASTER-1', 'TS': 1, 'TGID': 1, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2,], 'OFF': [9,10], 'RESET': []},
|
||||
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 1, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2,], 'OFF': [9,10], 'RESET': []},
|
||||
{'SYSTEM': 'CLIENT-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'ON', 'ON': [2,], 'OFF': [9,10], 'RESET': []},
|
||||
],
|
||||
'ENGLISH': [
|
||||
{'SYSTEM': 'MASTER-1', 'TS': 1, 'TGID': 13, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8,10], 'RESET': []},
|
||||
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 13, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8,10], 'RESET': []},
|
||||
{'SYSTEM': 'CLIENT-2', 'TS': 1, 'TGID': 13, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [3,], 'OFF': [8,10], 'RESET': []},
|
||||
],
|
||||
'STATEWIDE': [
|
||||
{'SYSTEM': 'MASTER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7,10], 'RESET': []},
|
||||
{'SYSTEM': 'SERVER-1', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7,10], 'RESET': []},
|
||||
{'SYSTEM': 'CLIENT-2', 'TS': 2, 'TGID': 3129, 'ACTIVE': True, 'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [4,], 'OFF': [7,10], 'RESET': []},
|
||||
]
|
||||
}
|
||||
|
||||
'''
|
||||
list the names of each system that should bridge unit to unit (individual) calls.
|
||||
OpenBridge (OBP) systems are NOT configured as inline BRIDGES members above.
|
||||
An OBP is a point-to-point trunk that passes talkgroups by TGID -- it has no RF
|
||||
user to key ON/OFF timers and no real timeslot -- so it gets its own table:
|
||||
|
||||
OBP_BRIDGES = { <OBP system from hblink.cfg> : { <bridge name> : <TGID> } }
|
||||
|
||||
Read a row as "on THIS OpenBridge, this TGID *is* this bridge." That one fact is
|
||||
the route in BOTH directions and the filter: a TGID not listed for an OBP is
|
||||
dropped (fail-closed). At startup bridge.py expands each row into a bridge member
|
||||
for you. Notes:
|
||||
* TS defaults to 1 (OBP "no timeslot" placeholder). To override: (TGID, TS).
|
||||
* The OBP system name must be an enabled MODE: OPENBRIDGE system in hblink.cfg.
|
||||
* Leaving an OBP system as an inline BRIDGES member above is a startup ERROR.
|
||||
* On one OBP, a TGID may map to only one bridge (a fork duplicates the stream)
|
||||
-> startup ERROR. A bridge carrying different TGIDs on two OBPs (renumber in
|
||||
transit) is allowed but logs a WARNING.
|
||||
* OBP_BRIDGES is optional -- omit it or leave it {} if you run no OpenBridges.
|
||||
'''
|
||||
|
||||
OBP_BRIDGES = {
|
||||
# 'BACKBONE-OBP': {
|
||||
# 'WORLDWIDE': 1,
|
||||
# 'STATEWIDE': 3129,
|
||||
# 'ENGLISH': (13, 2), # TGID 13 pinned to TS 2 (override)
|
||||
# },
|
||||
}
|
||||
|
||||
'''
|
||||
UNIT: system names that should bridge unit-to-unit (individual/private) calls to each other.
|
||||
'''
|
||||
|
||||
UNIT = ['ONE', 'TWO']
|
||||
@@ -61,4 +74,5 @@ like it were a Python program itself will tell you if the syntax is correct!
|
||||
if __name__ == '__main__':
|
||||
from pprint import pprint
|
||||
pprint(BRIDGES)
|
||||
pprint(OBP_BRIDGES)
|
||||
print(UNIT)
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python
|
||||
# Convenience launcher for the HBlink3 dashboard. Equivalent to running
|
||||
# `python dashboard/server.py`.
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard'))
|
||||
import server
|
||||
|
||||
if __name__ == '__main__':
|
||||
server.main()
|
||||
@@ -0,0 +1,23 @@
|
||||
# Tests
|
||||
|
||||
Regression tests pinning the behavior of helpers extracted during the 2026
|
||||
cleanup. They exist because a live OBP/HBP network with real DMR audio cannot be
|
||||
reproduced in a dev environment, so the routing helpers are validated in
|
||||
isolation instead.
|
||||
|
||||
- `test_acl.py` — `HBSYSTEM.dmrd_acl_check()` (the shared master/peer ACL logic):
|
||||
PERMIT/DENY matching, per-timeslot scoping, and the once-per-stream drop logging.
|
||||
- `test_lc.py` — `gen_lcs()` and `embed_lc()` from `bridge.py`: Link Control
|
||||
generation and the DMR payload LC rewrite used by all four group-routing paths.
|
||||
|
||||
## Running
|
||||
|
||||
From the repo root, using the project virtualenv (which has `twisted`,
|
||||
`dmr_utils3`, and `bitarray`):
|
||||
|
||||
```
|
||||
venv/bin/python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Pure stdlib `unittest` — no extra dependencies. They also run under `pytest` if
|
||||
you have it installed (`venv/bin/python -m pytest tests`).
|
||||
@@ -0,0 +1,396 @@
|
||||
{
|
||||
"hbp_contention_second_stream_blocked": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c021000000184ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001801060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
|
||||
]
|
||||
],
|
||||
"hbp_multi_target": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c021000000174ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
|
||||
],
|
||||
[
|
||||
"SERVER-2",
|
||||
"444d52440004c2c0000c1c0004c2c021000000174ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c021000000174ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001701060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-2",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001701060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001701060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0020000001701060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-2",
|
||||
"444d52440004c2c0000c1c0004c2c0020000001701060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0020000001701060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0030000001701060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-2",
|
||||
"444d52440004c2c0000c1c0004c2c0030000001701060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0030000001701060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0040000001701060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-2",
|
||||
"444d52440004c2c0000c1c0004c2c0040000001701060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0040000001701060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c022000000174c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
|
||||
],
|
||||
[
|
||||
"SERVER-2",
|
||||
"444d52440004c2c0000c1c0004c2c022000000174c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c022000000174c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f9"
|
||||
]
|
||||
],
|
||||
"hbp_to_hbp_cross_slot": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0a1000000124ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0810000001201060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0820000001201060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0830000001201060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0840000001201060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0a2000000124c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
|
||||
]
|
||||
],
|
||||
"hbp_to_hbp_no_voice_header": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001a01060b10151a1f24292e33383d424030c0a0ab60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0020000001a01060b10151a1f24292e33383d42414140041b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0220000001a2c5a0fe4147400e82c806ac13d42474c51565b606514236041220da44800de84b40000"
|
||||
]
|
||||
],
|
||||
"hbp_to_hbp_same_slot": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c021000000114ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca0000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001101060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0020000001101060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0030000001101060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c0040000001101060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0000c1c0004c2c022000000114c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f90000"
|
||||
]
|
||||
],
|
||||
"hbp_to_hbp_tgid_rewrite": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0007bb10004c2c021000000134834170434a6707c2caee6593d42474c51565b6064ce21d4c58f8c4b4953f4b0920000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0007bb10004c2c0010000001301060b10151a1f24292e33383d424aaddd293b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0007bb10004c2c0020000001301060b10151a1f24292e33383d424958d9011b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0007bb10004c2c0030000001301060b10151a1f24292e33383d4249081063ab60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0007bb10004c2c0040000001301060b10151a1f24292e33383d42488cfb772b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c0007bb10004c2c02200000013485b17d0341670042cdee6f93d42474c51565b6065da22ecc2ef800b4153edb0a10000"
|
||||
]
|
||||
],
|
||||
"hbp_to_obp": [
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c021000000144ce00f1015ee05fc2ffe6b993d42474c51565b60644e21b4c2ef08aa5150d0b4ca"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0010000001401060b10151a1f24292e33383d424a3ccca8bb60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0020000001401060b10151a1f24292e33383d42495958100b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0030000001401060b10151a1f24292e33383d42481812d3ab60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c0040000001401060b10151a1f24292e33383d42499ed9c50b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c0000c1c0004c2c022000000144c8f0fc4155e05842f8e6b393d42474c51565b60655a228cc58f04ea5950c9b4f9"
|
||||
]
|
||||
],
|
||||
"obp_to_hbp": [
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000c1c002fbf0c21000000154c1f081816aa0bdc2b6e46597d42474c51565b60678622e4c28f194a08d07434ad0000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000c1c002fbf0c010000001501060b10151a1f24292e33383d424a0c9ca8bb60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000c1c002fbf0c020000001501060b10151a1f24292e33383d42490938100b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000c1c002fbf0c030000001501060b10151a1f24292e33383d42482840339b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000c1c002fbf0c040000001501060b10151a1f24292e33383d4249fc5b17db60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000c1c002fbf0c22000000154c7008cc161a0ba42b1e46f97d42474c51565b60669221dcc5ef150a00d06d349e0000"
|
||||
]
|
||||
],
|
||||
"obp_to_obp": [
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c21000000164c1f081816aa0bdc2b6e46597d42474c51565b60678622e4c28f194a08d07434ad"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c010000001601060b10151a1f24292e33383d424a0c9ca8bb60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c020000001601060b10151a1f24292e33383d42490938100b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c030000001601060b10151a1f24292e33383d42482840339b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c040000001601060b10151a1f24292e33383d4249fc5b17db60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c22000000164c7008cc161a0ba42b1e46f97d42474c51565b60669221dcc5ef150a00d06d349e"
|
||||
]
|
||||
],
|
||||
"obp_to_obp_no_voice_header": [
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c010000001b01060b10151a1f24292e33383d42400090a0ab60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000c1c002fbf0c220000001b2ca508ec17300ec8281047017d42474c51565b6066dc203041421c4411807a04d3"
|
||||
]
|
||||
],
|
||||
"unit_hbp_to_hbp": [
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c00008200004c2c0610000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c00008200004c2c0410000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c00008200004c2c0420000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c00008200004c2c0430000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c00008200004c2c0440000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"REPEATER-1",
|
||||
"444d52440004c2c00008200004c2c0620000002101060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
]
|
||||
],
|
||||
"unit_hbp_to_obp": [
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0610000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0410000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0420000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0430000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0440000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0620000002201060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
]
|
||||
],
|
||||
"unit_hbp_to_obp_ts2": [
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0610000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0410000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0420000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0430000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0440000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-1",
|
||||
"444d52440004c2c00008200004c2c0620000002301060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
]
|
||||
],
|
||||
"unit_obp_to_hbp": [
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000820002fbf0c610000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000820002fbf0c410000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000820002fbf0c420000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000820002fbf0c430000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000820002fbf0c440000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
],
|
||||
[
|
||||
"SERVER-1",
|
||||
"444d5244000004d2000820002fbf0c620000002401060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca10000"
|
||||
]
|
||||
],
|
||||
"unit_obp_to_obp": [
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000820002fbf0c610000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000820002fbf0c410000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000820002fbf0c420000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000820002fbf0c430000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000820002fbf0c440000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
],
|
||||
[
|
||||
"OBP-2",
|
||||
"444d5244000004d2000820002fbf0c620000002501060b10151a1f24292e33383d42474c51565b60656a6f74797e83888d92979ca1"
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Deterministic routing scenarios used to build a byte-level golden baseline of
|
||||
# the current routing core, so the planned forward_group extraction (and later
|
||||
# the full unification) can be proven to emit identical packets.
|
||||
#
|
||||
# Each scenario builds its own World, replays a fixed frame sequence, and returns
|
||||
# the captured emissions as a list of [system, hex]. Emissions are deterministic:
|
||||
# packet bytes depend only on the input frames and the (fixed) BPTC-encoded LCs,
|
||||
# never on wall-clock time.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from dmr_utils3.utils import bytes_3, bytes_4
|
||||
import harness
|
||||
|
||||
|
||||
def _member(system, ts, tgid):
|
||||
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
|
||||
|
||||
|
||||
def _full_call(w, src, *, rf_src, dst, peer, slot, stream_id):
|
||||
"""A complete group call: voice header, bursts B-E, voice terminator."""
|
||||
w.feed_group_header(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
|
||||
for burst in (1, 2, 3, 4):
|
||||
w.feed_group_burst(src, burst, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
|
||||
w.feed_group_terminator(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
|
||||
|
||||
|
||||
def _full_unit_call(w, src, *, rf_src, dst, peer, slot, stream_id):
|
||||
w.feed_unit_header(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
|
||||
for burst in (1, 2, 3, 4):
|
||||
w.feed_unit_burst(src, burst, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
|
||||
w.feed_unit_terminator(src, rf_src=rf_src, dst=dst, peer=peer, slot=slot, stream_id=stream_id)
|
||||
|
||||
|
||||
def _hex(captures):
|
||||
return [[name, pkt.hex()] for (name, pkt) in captures]
|
||||
|
||||
|
||||
# ---- scenarios ----
|
||||
|
||||
def hbp_to_hbp_same_slot():
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
|
||||
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x11')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def hbp_to_hbp_cross_slot():
|
||||
# Source on TS1, target subscribed on TS2 -> exercises the TS-bit flip.
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 2, 3100)]})
|
||||
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x12')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def hbp_to_hbp_tgid_rewrite():
|
||||
# Target subscribed on a different TGID -> destination TGID is rewritten.
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 31665)]})
|
||||
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x13')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def hbp_to_obp():
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('OBP-1', 1, 3100)]})
|
||||
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x14')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def obp_to_hbp():
|
||||
w = harness.World({'B': [_member('OBP-1', 1, 3100), _member('SERVER-1', 1, 3100)]})
|
||||
_full_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(3100),
|
||||
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x15')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def obp_to_obp():
|
||||
w = harness.World({'B': [_member('OBP-1', 1, 3100), _member('OBP-2', 1, 3100)]})
|
||||
_full_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(3100),
|
||||
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x16')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def hbp_multi_target():
|
||||
# One source bridged to an HBP outbound system, a second HBP server, and an OBP.
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100),
|
||||
_member('SERVER-2', 1, 3100), _member('OBP-1', 1, 3100)]})
|
||||
_full_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x17')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def hbp_to_hbp_no_voice_header():
|
||||
# First frame is a burst (no voice header) -> the LC is synthesized from the
|
||||
# header fields rather than decoded, exercising the other src_lc path.
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
|
||||
kw = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000),
|
||||
slot=1, stream_id=b'\x00\x00\x00\x1a')
|
||||
w.feed_group_burst('SERVER-1', 1, **kw)
|
||||
w.feed_group_burst('SERVER-1', 2, **kw)
|
||||
w.feed_group_terminator('SERVER-1', **kw)
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def obp_to_obp_no_voice_header():
|
||||
w = harness.World({'B': [_member('OBP-1', 1, 3100), _member('OBP-2', 1, 3100)]})
|
||||
kw = dict(rf_src=bytes_3(1234), dst=bytes_3(3100), peer=bytes_4(3129100),
|
||||
slot=1, stream_id=b'\x00\x00\x00\x1b')
|
||||
w.feed_group_burst('OBP-1', 1, **kw)
|
||||
w.feed_group_terminator('OBP-1', **kw)
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def hbp_contention_second_stream_blocked():
|
||||
# Two streams on the same TGID/slot; the second must not produce a duplicate
|
||||
# forwarded call while the first is active.
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
|
||||
a = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000),
|
||||
slot=1, stream_id=b'\x00\x00\x00\x18')
|
||||
b = dict(rf_src=bytes_3(312999), dst=bytes_3(3100), peer=bytes_4(312000),
|
||||
slot=1, stream_id=b'\x00\x00\x00\x19')
|
||||
w.feed_group_header('SERVER-1', **a)
|
||||
w.feed_group_burst('SERVER-1', 1, **a)
|
||||
w.feed_group_header('SERVER-1', **b) # contends with the active call
|
||||
w.feed_group_burst('SERVER-1', 1, **b)
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def unit_hbp_to_hbp():
|
||||
w = harness.World({}, unit_systems=['SERVER-1'], extra_systems=['REPEATER-1'])
|
||||
w.seed_unit_map(bytes_3(2080), 'REPEATER-1')
|
||||
_full_unit_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(2080),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x21')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def unit_hbp_to_obp():
|
||||
w = harness.World({}, unit_systems=['SERVER-1'], extra_systems=['OBP-1'])
|
||||
w.seed_unit_map(bytes_3(2080), 'OBP-1')
|
||||
_full_unit_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(2080),
|
||||
peer=bytes_4(312000), slot=1, stream_id=b'\x00\x00\x00\x22')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def unit_hbp_to_obp_ts2():
|
||||
# Unit on TS2 to an OBP (BOTH_SLOTS False) -> slot bit cleared on egress.
|
||||
w = harness.World({}, unit_systems=['SERVER-1'], extra_systems=['OBP-1'])
|
||||
w.seed_unit_map(bytes_3(2080), 'OBP-1')
|
||||
_full_unit_call(w, 'SERVER-1', rf_src=bytes_3(312000), dst=bytes_3(2080),
|
||||
peer=bytes_4(312000), slot=2, stream_id=b'\x00\x00\x00\x23')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def unit_obp_to_hbp():
|
||||
w = harness.World({}, unit_systems=[], extra_systems=['OBP-1', 'SERVER-1'])
|
||||
w.seed_unit_map(bytes_3(2080), 'SERVER-1')
|
||||
_full_unit_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(2080),
|
||||
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x24')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
def unit_obp_to_obp():
|
||||
w = harness.World({}, unit_systems=[], extra_systems=['OBP-1', 'OBP-2'])
|
||||
w.seed_unit_map(bytes_3(2080), 'OBP-2')
|
||||
_full_unit_call(w, 'OBP-1', rf_src=bytes_3(1234), dst=bytes_3(2080),
|
||||
peer=bytes_4(3129100), slot=1, stream_id=b'\x00\x00\x00\x25')
|
||||
return _hex(w.captures)
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
'hbp_to_hbp_same_slot': hbp_to_hbp_same_slot,
|
||||
'hbp_to_hbp_cross_slot': hbp_to_hbp_cross_slot,
|
||||
'hbp_to_hbp_tgid_rewrite': hbp_to_hbp_tgid_rewrite,
|
||||
'hbp_to_obp': hbp_to_obp,
|
||||
'obp_to_hbp': obp_to_hbp,
|
||||
'obp_to_obp': obp_to_obp,
|
||||
'hbp_to_hbp_no_voice_header': hbp_to_hbp_no_voice_header,
|
||||
'obp_to_obp_no_voice_header': obp_to_obp_no_voice_header,
|
||||
'hbp_multi_target': hbp_multi_target,
|
||||
'hbp_contention_second_stream_blocked': hbp_contention_second_stream_blocked,
|
||||
'unit_hbp_to_hbp': unit_hbp_to_hbp,
|
||||
'unit_hbp_to_obp': unit_hbp_to_obp,
|
||||
'unit_hbp_to_obp_ts2': unit_hbp_to_obp_ts2,
|
||||
'unit_obp_to_hbp': unit_obp_to_hbp,
|
||||
'unit_obp_to_obp': unit_obp_to_obp,
|
||||
}
|
||||
|
||||
|
||||
def run_all():
|
||||
return {name: fn() for name, fn in sorted(SCENARIOS.items())}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
print(json.dumps(run_all(), indent=2))
|
||||
@@ -0,0 +1,126 @@
|
||||
# Self-contained config for the routing test harness. Defines two HBP servers,
|
||||
# one HBP outbound peer, and two OpenBridges so scenarios can exercise every
|
||||
# source/target combination (HBP<->HBP, HBP<->OBP, OBP<->OBP). Ports/IPs are
|
||||
# never bound -- the harness drives dmrd_received directly and captures
|
||||
# send_system.
|
||||
|
||||
[GLOBAL]
|
||||
PATH: ./
|
||||
PING_TIME: 5
|
||||
MAX_MISSED: 3
|
||||
USE_ACL: False
|
||||
REG_ACL: PERMIT:ALL
|
||||
SUB_ACL: PERMIT:ALL
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
[REPORTS]
|
||||
REPORT: False
|
||||
REPORT_INTERVAL: 60
|
||||
REPORT_PORT: 4321
|
||||
REPORT_CLIENTS: 127.0.0.1
|
||||
|
||||
[LOGGER]
|
||||
LOG_FILE: /dev/null
|
||||
LOG_HANDLERS: null
|
||||
LOG_LEVEL: CRITICAL
|
||||
LOG_NAME: HBlink
|
||||
|
||||
[ALIASES]
|
||||
TRY_DOWNLOAD: False
|
||||
PATH: ./
|
||||
PEER_FILE: peer_ids.json
|
||||
SUBSCRIBER_FILE: subscriber_ids.json
|
||||
TGID_FILE: talkgroup_ids.json
|
||||
PEER_URL: https://www.radioid.net/static/rptrs.json
|
||||
SUBSCRIBER_URL: https://www.radioid.net/static/users.json
|
||||
STALE_DAYS: 7
|
||||
|
||||
[SERVER-1]
|
||||
MODE: SERVER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_REPEATERS: 10
|
||||
IP: 0.0.0.0
|
||||
PORT: 54001
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 5
|
||||
USE_ACL: False
|
||||
REG_ACL: PERMIT:ALL
|
||||
SUB_ACL: PERMIT:ALL
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
[SERVER-2]
|
||||
MODE: SERVER
|
||||
ENABLED: True
|
||||
REPEAT: True
|
||||
MAX_REPEATERS: 10
|
||||
IP: 0.0.0.0
|
||||
PORT: 54002
|
||||
PASSPHRASE: passw0rd
|
||||
GROUP_HANGTIME: 5
|
||||
USE_ACL: False
|
||||
REG_ACL: PERMIT:ALL
|
||||
SUB_ACL: PERMIT:ALL
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
[REPEATER-1]
|
||||
MODE: OUTBOUND
|
||||
ENABLED: True
|
||||
LOOSE: False
|
||||
IP: 0.0.0.0
|
||||
PORT: 54003
|
||||
SERVER_IP: 127.0.0.1
|
||||
SERVER_PORT: 54001
|
||||
PASSPHRASE: homebrew
|
||||
CALLSIGN: W1ABC
|
||||
RADIO_ID: 312000
|
||||
RX_FREQ: 449000000
|
||||
TX_FREQ: 444000000
|
||||
TX_POWER: 25
|
||||
COLORCODE: 1
|
||||
SLOTS: 1
|
||||
LATITUDE: 38.0000
|
||||
LONGITUDE: -095.0000
|
||||
HEIGHT: 75
|
||||
LOCATION: Anywhere, USA
|
||||
DESCRIPTION: harness peer
|
||||
URL: www.w1abc.org
|
||||
SOFTWARE_ID: 20170620
|
||||
PACKAGE_ID: MMDVM_HBlink
|
||||
GROUP_HANGTIME: 5
|
||||
OPTIONS:
|
||||
USE_ACL: False
|
||||
SUB_ACL: PERMIT:ALL
|
||||
TGID_TS1_ACL: PERMIT:ALL
|
||||
TGID_TS2_ACL: PERMIT:ALL
|
||||
|
||||
[OBP-1]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: True
|
||||
IP: 0.0.0.0
|
||||
PORT: 62031
|
||||
NETWORK_ID: 3129100
|
||||
PASSPHRASE: password
|
||||
TARGET_IP: 127.0.0.1
|
||||
TARGET_PORT: 62041
|
||||
BOTH_SLOTS: False
|
||||
USE_ACL: False
|
||||
SUB_ACL: PERMIT:ALL
|
||||
TGID_ACL: PERMIT:ALL
|
||||
|
||||
[OBP-2]
|
||||
MODE: OPENBRIDGE
|
||||
ENABLED: True
|
||||
IP: 0.0.0.0
|
||||
PORT: 62032
|
||||
NETWORK_ID: 3129200
|
||||
PASSPHRASE: password
|
||||
TARGET_IP: 127.0.0.1
|
||||
TARGET_PORT: 62042
|
||||
BOTH_SLOTS: False
|
||||
USE_ACL: False
|
||||
SUB_ACL: PERMIT:ALL
|
||||
TGID_ACL: PERMIT:ALL
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Frame-replay test harness for the bridge routing core.
|
||||
#
|
||||
# It stands up the REAL routerHBP / routerOBP instances against a small config,
|
||||
# drives synthetic DMRD frames through their routing entry point (dmrd_received),
|
||||
# and captures the packets each system would emit (send_system). A controllable
|
||||
# clock makes contention/hangtime deterministic.
|
||||
#
|
||||
# Purpose: characterize the current routing behavior and, during the planned
|
||||
# unification, run identical frame sequences through the old and new code and
|
||||
# assert byte-identical emissions -- so OBP multi-stream behavior and HBP slot
|
||||
# locking can be proven preserved without a live over-the-air network.
|
||||
#
|
||||
# This module is scaffolding, not a test itself; see test_harness_smoke.py.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bridge
|
||||
import config
|
||||
|
||||
# Keep the routers quiet during replay (they log heavily at INFO). Raise only
|
||||
# their own logger levels rather than logging.disable() -- a global disable would
|
||||
# also defeat assertLogs() checks in other test modules. setLevel is fine because
|
||||
# assertLogs temporarily lowers the level within its own context.
|
||||
for _name in ('bridge', 'hblink'):
|
||||
logging.getLogger(_name).setLevel(logging.CRITICAL)
|
||||
|
||||
# Self-contained test topology: two HBP masters, one HBP peer, two OpenBridges.
|
||||
TEST_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'harness.cfg')
|
||||
|
||||
# Bit-field constants for assembling the DMRD flags byte (see datagramReceived).
|
||||
_FT_VOICE = 0 # HBPF_VOICE
|
||||
_FT_DATA_SYNC = 2 # HBPF_DATA_SYNC
|
||||
_VHEAD = 1 # HBPF_SLT_VHEAD
|
||||
_VTERM = 2 # HBPF_SLT_VTERM
|
||||
|
||||
|
||||
class Clock:
|
||||
"""A manually advanced stand-in for time.time()."""
|
||||
def __init__(self, start=1_000_000.0):
|
||||
self.t = start
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
def tick(self, seconds):
|
||||
self.t += seconds
|
||||
return self.t
|
||||
|
||||
|
||||
def mk_dmrd(seq, rf_src, dst, peer, slot, call_type, frame_type, dtype_vseq,
|
||||
stream_id, dmrpkt=None):
|
||||
"""Assemble a 55-byte DMRD packet (33-byte payload + 2 BER/RSSI bytes)."""
|
||||
if dmrpkt is None:
|
||||
dmrpkt = bytes((i * 5 + 1) & 0xFF for i in range(33))
|
||||
assert len(rf_src) == 3 and len(dst) == 3 and len(peer) == 4 and len(stream_id) == 4
|
||||
assert len(dmrpkt) == 33
|
||||
bits = 0
|
||||
if slot == 2:
|
||||
bits |= 0x80
|
||||
if call_type == 'unit':
|
||||
bits |= 0x40
|
||||
bits |= (frame_type & 0x3) << 4
|
||||
bits |= (dtype_vseq & 0xF)
|
||||
return b''.join([b'DMRD', bytes([seq]), rf_src, dst, peer,
|
||||
bytes([bits]), stream_id, dmrpkt, b'\x00\x00'])
|
||||
|
||||
|
||||
class World:
|
||||
"""A small routing world: a set of systems joined to bridges, with a clock
|
||||
and an emission-capture log."""
|
||||
|
||||
def __init__(self, bridges, unit_systems=None, extra_systems=None):
|
||||
# Fresh, isolated module state for the bridge module.
|
||||
self.clock = Clock()
|
||||
bridge.time = self.clock # monkeypatch the clock the routers read
|
||||
|
||||
self.CONFIG = config.build_config(TEST_CFG)
|
||||
self.CONFIG['REPORTS']['REPORT'] = False
|
||||
bridge.CONFIG = self.CONFIG
|
||||
|
||||
# Aliases used only for logging; empty is fine (get_alias returns the id).
|
||||
bridge.subscriber_ids = {}
|
||||
bridge.peer_ids = {}
|
||||
bridge.talkgroup_ids = {}
|
||||
|
||||
bridge.UNIT_MAP = {}
|
||||
bridge.UNIT = list(unit_systems or [])
|
||||
|
||||
# Build the BRIDGES structure and indexes from the supplied spec.
|
||||
bridge.BRIDGES = bridge.make_bridges(bridges)
|
||||
bridge.BRIDGE_SRC_INDEX, bridge.BRIDGE_BY_SYSTEM = bridge.index_bridges(bridge.BRIDGES)
|
||||
|
||||
# Instantiate the real router objects for every system referenced.
|
||||
self.captures = []
|
||||
bridge.systems.clear()
|
||||
names = set(unit_systems or []) | set(extra_systems or [])
|
||||
for b in bridges:
|
||||
for member in bridges[b]:
|
||||
names.add(member['SYSTEM'])
|
||||
for name in names:
|
||||
mode = self.CONFIG['SYSTEMS'][name]['MODE']
|
||||
if mode == 'OPENBRIDGE':
|
||||
obj = bridge.routerOBP(name, self.CONFIG, None)
|
||||
else:
|
||||
obj = bridge.routerHBP(name, self.CONFIG, None)
|
||||
# The router only forwards to an OUTBOUND system once it has
|
||||
# logged into its upstream server; the harness models connected,
|
||||
# participating systems, so mark outbound systems as connected.
|
||||
if mode == 'OUTBOUND':
|
||||
obj._stats['CONNECTION'] = 'YES'
|
||||
# Capture what the routing core hands to each system to transmit,
|
||||
# instead of writing to a real UDP transport.
|
||||
obj.send_system = self._make_capture(name)
|
||||
bridge.systems[name] = obj
|
||||
|
||||
# The STATUS tables were initialized with the current clock value. Advance
|
||||
# well past any hangtime/stream window so the first real frame isn't seen
|
||||
# as colliding with construction-time state (matches "started, then idle").
|
||||
self.clock.tick(3600.0)
|
||||
|
||||
def _make_capture(self, name):
|
||||
def _capture(_packet):
|
||||
self.captures.append((name, bytes(_packet)))
|
||||
return _capture
|
||||
|
||||
def feed(self, src, *, rf_src, dst, peer, slot, stream_id, call_type,
|
||||
frame_type, dtype_vseq, seq=0):
|
||||
"""Drive one DMRD frame into src's routing entry point."""
|
||||
data = mk_dmrd(seq, rf_src, dst, peer, slot, call_type,
|
||||
frame_type, dtype_vseq, stream_id)
|
||||
bridge.systems[src].dmrd_received(
|
||||
peer, rf_src, dst, seq, slot, call_type,
|
||||
frame_type, dtype_vseq, stream_id, data)
|
||||
|
||||
def feed_group_header(self, src, **kw):
|
||||
self.feed(src, call_type='group', frame_type=_FT_DATA_SYNC,
|
||||
dtype_vseq=_VHEAD, **kw)
|
||||
|
||||
def feed_group_terminator(self, src, **kw):
|
||||
self.feed(src, call_type='group', frame_type=_FT_DATA_SYNC,
|
||||
dtype_vseq=_VTERM, **kw)
|
||||
|
||||
def feed_group_burst(self, src, burst, **kw):
|
||||
self.feed(src, call_type='group', frame_type=_FT_VOICE,
|
||||
dtype_vseq=burst, **kw)
|
||||
|
||||
def feed_unit_header(self, src, **kw):
|
||||
self.feed(src, call_type='unit', frame_type=_FT_DATA_SYNC,
|
||||
dtype_vseq=_VHEAD, **kw)
|
||||
|
||||
def feed_unit_terminator(self, src, **kw):
|
||||
self.feed(src, call_type='unit', frame_type=_FT_DATA_SYNC,
|
||||
dtype_vseq=_VTERM, **kw)
|
||||
|
||||
def feed_unit_burst(self, src, burst, **kw):
|
||||
self.feed(src, call_type='unit', frame_type=_FT_VOICE,
|
||||
dtype_vseq=burst, **kw)
|
||||
|
||||
def seed_unit_map(self, subscriber, system):
|
||||
"""Register that `subscriber` (3 bytes) was last heard on `system`, so a
|
||||
unit call to it routes there."""
|
||||
bridge.UNIT_MAP[subscriber] = (system, self.clock())
|
||||
|
||||
def drain(self):
|
||||
"""Return and clear captured emissions as a list of (system, bytes)."""
|
||||
out = self.captures[:]
|
||||
self.captures.clear()
|
||||
return out
|
||||
|
||||
def emitted_to(self, system):
|
||||
return [pkt for (name, pkt) in self.captures if name == system]
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Live UDP traffic harness: logs into a running HBlink3 SERVER as a HomeBrew
|
||||
# peer and injects DMRD voice streams over a real socket. Unlike tests/harness.py
|
||||
# (which drives dmrd_received in-process), this exercises the full server path --
|
||||
# socket -> server handshake -> ACL -> bridge routing -> contention -> reporting --
|
||||
# so it can drive the dashboard with realistic traffic.
|
||||
#
|
||||
# It is deliberately CONTROLLABLE so we can tell expected contention from a bug:
|
||||
#
|
||||
# default one clean, fully-terminated call at a time -> expect ZERO
|
||||
# collisions in the bridge log.
|
||||
# --concurrent K fire K overlapping streams (distinct stream-ids) on the
|
||||
# same TGID/slot at once -> SHOULD collide (correct DMR).
|
||||
# --no-term omit the voice terminator (simulate a dropped tail) ->
|
||||
# leaves the target slot "busy"; the NEXT call should then
|
||||
# collide until the stream times out. This is the stuck-
|
||||
# stream condition the supersession / 2s-timeout fix targets.
|
||||
#
|
||||
# Usage (against the sample hblink.cfg SERVER-1):
|
||||
# venv/bin/python tests/live_harness.py --calls 5
|
||||
# venv/bin/python tests/live_harness.py --concurrent 3 --calls 3
|
||||
# venv/bin/python tests/live_harness.py --no-term --calls 3 --gap 1
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from binascii import a2b_hex as bhex
|
||||
from hashlib import sha256
|
||||
|
||||
# --- wire constants (mirror const.py) ---
|
||||
DMRD = b'DMRD'
|
||||
RPTL = b'RPTL'
|
||||
RPTK = b'RPTK'
|
||||
RPTC = b'RPTC'
|
||||
RPTACK = b'RPTACK'
|
||||
MSTNAK = b'MSTNAK'
|
||||
RPTPING = b'RPTPING'
|
||||
|
||||
_FT_VOICE = 0 # HBPF_VOICE
|
||||
_FT_DATA_SYNC = 2 # HBPF_DATA_SYNC
|
||||
_VHEAD = 1 # HBPF_SLT_VHEAD
|
||||
_VTERM = 2 # HBPF_SLT_VTERM
|
||||
|
||||
|
||||
def mk_dmrd(seq, rf_src, dst, peer, slot, call_type, frame_type, dtype_vseq, stream_id):
|
||||
"""Assemble a 55-byte DMRD packet (33-byte payload + 2 BER/RSSI bytes)."""
|
||||
dmrpkt = bytes((i * 5 + 1) & 0xFF for i in range(33))
|
||||
bits = 0
|
||||
if slot == 2:
|
||||
bits |= 0x80
|
||||
if call_type == 'unit':
|
||||
bits |= 0x40
|
||||
bits |= (frame_type & 0x3) << 4
|
||||
bits |= (dtype_vseq & 0xF)
|
||||
return b''.join([DMRD, bytes([seq & 0xFF]), rf_src, dst, peer,
|
||||
bytes([bits]), stream_id, dmrpkt, b'\x00\x00'])
|
||||
|
||||
|
||||
def b3(n): return n.to_bytes(3, 'big')
|
||||
def b4(n): return n.to_bytes(4, 'big')
|
||||
|
||||
|
||||
def login(sock, addr, peer_id, passphrase):
|
||||
"""Run the HBP login handshake. Returns when CONNECTED or raises."""
|
||||
def expect(prefix):
|
||||
sock.settimeout(5.0)
|
||||
data, _ = sock.recvfrom(1024)
|
||||
if data[:len(MSTNAK)] == MSTNAK:
|
||||
raise RuntimeError('server sent MSTNAK (login refused) -- check ACL / peer id')
|
||||
if data[:len(prefix)] != prefix:
|
||||
raise RuntimeError('expected %r, got %r' % (prefix, data[:10]))
|
||||
return data
|
||||
|
||||
sock.sendto(RPTL + b4(peer_id), addr)
|
||||
ack = expect(RPTACK)
|
||||
salt = ack[6:10]
|
||||
h = bhex(sha256(salt + passphrase).hexdigest())
|
||||
sock.sendto(RPTK + b4(peer_id) + h, addr)
|
||||
expect(RPTACK)
|
||||
|
||||
# Repeater config blob (302 bytes total incl. RPTC + peer_id).
|
||||
cfg = b''.join([
|
||||
b'HRNS '[:8], # callsign [8:16]
|
||||
b'449000000', # rx_freq [16:25]
|
||||
b'444000000', # tx_freq [25:34]
|
||||
b'25', # tx_power [34:36]
|
||||
b'01', # colorcode [36:38]
|
||||
b'38.00000', # lat [38:46]
|
||||
b'-095.0000', # lon [46:55]
|
||||
b'075', # height [55:58]
|
||||
b'Test Harness '[:20], # location [58:78]
|
||||
b'live_harness.py '[:19], # description [78:97]
|
||||
b'1', # slots [97:98]
|
||||
b' ' * 124, # url [98:222]
|
||||
b'hblink-harness'.ljust(40), # software_id [222:262]
|
||||
b''.ljust(40), # package_id [262:302]
|
||||
])
|
||||
sock.sendto(RPTC + b4(peer_id) + cfg, addr)
|
||||
expect(RPTACK)
|
||||
print('[harness] logged in as peer %d' % peer_id, flush=True)
|
||||
|
||||
|
||||
def send_stream(sock, addr, peer_id, src, dst, slot, call_type, stream_id,
|
||||
superframes, frame_ms, terminate, tail_vseq=None):
|
||||
"""Send one DMR call and return the vseq of its last voice burst.
|
||||
|
||||
A *properly terminated* call always finishes a complete superframe (voice
|
||||
bursts vseq 0..5) and then sends the terminator, so the last voice burst
|
||||
before VTERM is always F (vseq 5) -- this is how real radios end a call.
|
||||
|
||||
A *lost tail* (terminate=False) stops mid-superframe with no terminator,
|
||||
leaving the last burst at `tail_vseq` (random 0..5 if None). This models an
|
||||
RF drop and is the case the stream-timeout / supersession END logic must
|
||||
still catch -- including when the last burst lands on vseq 2 (== VTERM's
|
||||
numeric value), which historically defeated the "already terminated" check.
|
||||
"""
|
||||
seq = [0]
|
||||
|
||||
def send(ft, vseq):
|
||||
sock.sendto(mk_dmrd(seq[0], b3(src), b3(dst), b4(peer_id), slot, call_type,
|
||||
ft, vseq, stream_id), addr)
|
||||
seq[0] = (seq[0] + 1) & 0xFF
|
||||
|
||||
send(_FT_DATA_SYNC, _VHEAD)
|
||||
if terminate:
|
||||
for _ in range(superframes):
|
||||
for vseq in range(6):
|
||||
send(_FT_VOICE, vseq)
|
||||
time.sleep(frame_ms / 1000.0)
|
||||
send(_FT_DATA_SYNC, _VTERM)
|
||||
return 5
|
||||
|
||||
# Lost tail: whole superframes, then a partial one ending at tail_vseq.
|
||||
if tail_vseq is None:
|
||||
tail_vseq = random.randint(0, 5)
|
||||
for _ in range(max(0, superframes - 1)):
|
||||
for vseq in range(6):
|
||||
send(_FT_VOICE, vseq)
|
||||
time.sleep(frame_ms / 1000.0)
|
||||
for vseq in range(tail_vseq + 1):
|
||||
send(_FT_VOICE, vseq)
|
||||
time.sleep(frame_ms / 1000.0)
|
||||
return tail_vseq
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Live DMRD traffic harness for HBlink3')
|
||||
ap.add_argument('--server-ip', default='127.0.0.1')
|
||||
ap.add_argument('--port', type=int, default=54000)
|
||||
ap.add_argument('--passphrase', default='s3cr37w0rd')
|
||||
ap.add_argument('--peer-id', type=int, default=312100)
|
||||
ap.add_argument('--src', type=int, default=3121234, help='RF source (subscriber) id')
|
||||
ap.add_argument('--tgid', type=int, default=1)
|
||||
ap.add_argument('--slot', type=int, default=1, choices=(1, 2))
|
||||
ap.add_argument('--call-type', default='group', choices=('group', 'unit'))
|
||||
ap.add_argument('--calls', type=int, default=3, help='number of calls to place')
|
||||
ap.add_argument('--duration', type=float, default=2.0, help='seconds of audio per call')
|
||||
ap.add_argument('--gap', type=float, default=2.0, help='idle seconds between calls')
|
||||
ap.add_argument('--frame-ms', type=float, default=60.0, help='inter-frame spacing (DMR=60ms)')
|
||||
ap.add_argument('--concurrent', type=int, default=1,
|
||||
help='overlapping streams per call on the same TGID/slot (collision test)')
|
||||
ap.add_argument('--no-term', action='store_true',
|
||||
help='lost tail: stop mid-superframe with no terminator (dropped-RF test)')
|
||||
ap.add_argument('--tail-vseq', type=int, default=None, choices=range(6),
|
||||
help='with --no-term, force the last burst vseq (e.g. 2 to reproduce the END-loss bug); random if unset')
|
||||
args = ap.parse_args()
|
||||
|
||||
addr = (args.server_ip, args.port)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
login(sock, addr, args.peer_id, args.passphrase.encode())
|
||||
|
||||
# A superframe is 6 voice bursts (~360 ms). Round the requested audio
|
||||
# duration to whole superframes so a clean call always ends on a full one.
|
||||
superframes = max(1, round(args.duration / (6 * args.frame_ms / 1000.0)))
|
||||
terminate = not args.no_term
|
||||
# Random per-process stream-id base so concurrent harness instances (each a
|
||||
# distinct simulated repeater) don't emit colliding stream-ids -- real radios
|
||||
# pick random stream-ids, and identical ids would alias into one call.
|
||||
sid = random.randint(0x10000000, 0xF0000000)
|
||||
for c in range(args.calls):
|
||||
if args.concurrent <= 1:
|
||||
sid += 1
|
||||
tail = send_stream(sock, addr, args.peer_id, args.src, args.tgid, args.slot,
|
||||
args.call_type, b4(sid), superframes, args.frame_ms,
|
||||
terminate, args.tail_vseq)
|
||||
print('[harness] call %d/%d: src=%d tgid=%d ts=%d sid=%08x %s'
|
||||
% (c + 1, args.calls, args.src, args.tgid, args.slot, sid,
|
||||
'TERMINATED' if terminate else ('LOST TAIL, last burst vseq=%d' % tail)),
|
||||
flush=True)
|
||||
else:
|
||||
# Interleave K distinct streams superframe-by-superframe so they
|
||||
# genuinely overlap and contend for the one slot.
|
||||
streams = []
|
||||
for k in range(args.concurrent):
|
||||
sid += 1
|
||||
streams.append((b4(sid), args.src + k))
|
||||
print('[harness] call %d/%d: %d OVERLAPPING streams on tgid=%d ts=%d %s'
|
||||
% (c + 1, args.calls, args.concurrent, args.tgid, args.slot,
|
||||
'TERMINATED' if terminate else 'LOST TAIL'), flush=True)
|
||||
tails = {sidb: (args.tail_vseq if args.tail_vseq is not None else random.randint(0, 5))
|
||||
for sidb, _ in streams}
|
||||
seqs = {sidb: 0 for sidb, _ in streams}
|
||||
|
||||
def tx(sidb, s, ft, vseq):
|
||||
sock.sendto(mk_dmrd(seqs[sidb], b3(s), b3(args.tgid), b4(args.peer_id),
|
||||
args.slot, args.call_type, ft, vseq, sidb), addr)
|
||||
seqs[sidb] = (seqs[sidb] + 1) & 0xFF
|
||||
|
||||
for sidb, s in streams:
|
||||
tx(sidb, s, _FT_DATA_SYNC, _VHEAD)
|
||||
for sf in range(superframes):
|
||||
for vseq in range(6):
|
||||
for sidb, s in streams:
|
||||
# On a lost tail, a stream goes silent past its tail burst.
|
||||
if terminate or sf < superframes - 1 or vseq <= tails[sidb]:
|
||||
tx(sidb, s, _FT_VOICE, vseq)
|
||||
time.sleep(args.frame_ms / 1000.0)
|
||||
if terminate:
|
||||
for sidb, s in streams:
|
||||
tx(sidb, s, _FT_DATA_SYNC, _VTERM)
|
||||
if c + 1 < args.calls:
|
||||
# keep the link alive across the idle gap
|
||||
sock.sendto(RPTPING + b4(args.peer_id), addr)
|
||||
time.sleep(args.gap)
|
||||
print('[harness] done', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Lossy-repeater simulator. Logs into a running HBlink3 SERVER as a HomeBrew
|
||||
# repeater and sends keepalive pings with a deliberate drop rate, so you can watch
|
||||
# the dashboard's per-repeater ping-loss % climb (and the callsign turn gold)
|
||||
# WITHOUT needing a genuinely marginal RF link.
|
||||
#
|
||||
# How it stays honest:
|
||||
# * It pings cleanly for a short warmup first, so the server calibrates this
|
||||
# repeater's ping cadence before any loss is introduced (otherwise the very
|
||||
# first big gap would be mistaken for the normal interval).
|
||||
# * It never drops more than --max-consec pings in a row, so it can't trip
|
||||
# MAX_MISSED and get disconnected -- it stays up and simply shows loss.
|
||||
# * Keep --interval well under (PING_TIME * MAX_MISSED) seconds so even a run
|
||||
# of drops stays inside the timeout.
|
||||
#
|
||||
# Point --port / --passphrase / --peer-id at a real SERVER the repeater may join
|
||||
# (--peer-id just has to pass that server's REG_ACL). Use a peer-id that isn't one
|
||||
# of your real repeaters so it shows up as its own row.
|
||||
#
|
||||
# venv/bin/python tests/lossy_repeater.py --port 54000 --passphrase s3cr37w0rd \
|
||||
# --peer-id 3129999 --interval 5 --loss 0.25
|
||||
#
|
||||
# Ctrl-C to stop; the simulated repeater then times out and drops off on its own.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from live_harness import login, RPTPING, b4
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Simulate a lossy repeater (drops keepalive pings)')
|
||||
ap.add_argument('--server-ip', default='127.0.0.1')
|
||||
ap.add_argument('--port', type=int, default=54000, help="the SERVER system's port")
|
||||
ap.add_argument('--passphrase', default='s3cr37w0rd')
|
||||
ap.add_argument('--peer-id', type=int, default=3129999, help='repeater radio id (must pass REG_ACL)')
|
||||
ap.add_argument('--interval', type=float, default=5.0,
|
||||
help='seconds between pings; keep well under PING_TIME*MAX_MISSED')
|
||||
ap.add_argument('--loss', type=float, default=0.25, help='fraction of pings to drop (0..0.9)')
|
||||
ap.add_argument('--warmup', type=int, default=6,
|
||||
help='clean pings first so the server calibrates the cadence')
|
||||
ap.add_argument('--max-consec', type=int, default=2,
|
||||
help='never drop more than this many in a row (avoids the MAX_MISSED timeout)')
|
||||
args = ap.parse_args()
|
||||
|
||||
loss = max(0.0, min(0.9, args.loss))
|
||||
addr = (args.server_ip, args.port)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
login(sock, addr, args.peer_id, args.passphrase.encode())
|
||||
sock.setblocking(False)
|
||||
|
||||
ping = RPTPING + b4(args.peer_id)
|
||||
sent = dropped = consec = n = 0
|
||||
print('[lossy] peer %d pinging every %.1fs, offering ~%d%% loss (%d clean warmup pings). Ctrl-C to stop.'
|
||||
% (args.peer_id, args.interval, int(loss * 100), args.warmup), flush=True)
|
||||
try:
|
||||
while True:
|
||||
try: # drain MSTPONG replies
|
||||
while True:
|
||||
sock.recvfrom(1024)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
n += 1
|
||||
drop = (n > args.warmup) and (consec < args.max_consec) and (random.random() < loss)
|
||||
if drop:
|
||||
dropped += 1
|
||||
consec += 1
|
||||
else:
|
||||
sock.sendto(ping, addr)
|
||||
sent += 1
|
||||
consec = 0
|
||||
if n % 6 == 0:
|
||||
tot = sent + dropped
|
||||
print('[lossy] sent=%d dropped=%d (~%d%% offered loss)'
|
||||
% (sent, dropped, round(100 * dropped / tot) if tot else 0), flush=True)
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print('\n[lossy] stopping; the simulated repeater will time out and drop off shortly.', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Live OpenBridge (OBP) traffic harness for HBlink3.
|
||||
#
|
||||
# Unlike the HomeBrew peer harness (tests/live_harness.py), OpenBridge has no
|
||||
# login handshake: each DMRD frame is a 53-byte DMR payload followed by a 20-byte
|
||||
# HMAC-SHA1(passphrase, payload), and the receiver only accepts packets whose
|
||||
# SOURCE address matches its configured TARGET_SOCK. So this harness BINDS the
|
||||
# local socket to the OBP's TARGET_PORT and sends to the OBP's listen PORT.
|
||||
#
|
||||
# OpenBridge is NOT timeslot-bound: it carries an unlimited number of concurrent
|
||||
# streams keyed by stream-id. --streams N fires N overlapping calls at once so
|
||||
# you can see several simultaneous OBP activities (and, via bridging, the peer
|
||||
# OBP sending them all out concurrently) -- in contrast to the slot-bound HBP
|
||||
# side where only one call per slot survives.
|
||||
#
|
||||
# Example (matches the demo wiring this session set up):
|
||||
# tests/obp_harness.py --bind-port 62045 --obp-port 62035 --streams 3 --calls 2
|
||||
# tests/obp_harness.py --bind-port 62046 --obp-port 62036 --streams 3 --calls 2
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import socket
|
||||
import time
|
||||
from hashlib import sha1
|
||||
from hmac import new as hmac_new
|
||||
|
||||
DMRD = b'DMRD'
|
||||
_FT_VOICE = 0
|
||||
_FT_DATA_SYNC = 2
|
||||
_VHEAD = 1
|
||||
_VTERM = 2
|
||||
|
||||
|
||||
def b3(n): return n.to_bytes(3, 'big')
|
||||
def b4(n): return n.to_bytes(4, 'big')
|
||||
|
||||
|
||||
def mk_obp(seq, rf_src, dst, peer, ft, vseq, sid, passphrase):
|
||||
"""A 73-byte OpenBridge frame: 53-byte DMRD payload + 20-byte HMAC-SHA1.
|
||||
OpenBridge calls are always slot 1 (no slot bit)."""
|
||||
dmrpkt = bytes((i * 5 + 1) & 0xFF for i in range(33))
|
||||
bits = (ft & 0x3) << 4 | (vseq & 0xF) # group, slot 1
|
||||
data = b''.join([DMRD, bytes([seq & 0xFF]), b3(rf_src), b3(dst), b4(peer),
|
||||
bytes([bits]), b4(sid), dmrpkt]) # 53 bytes
|
||||
return data + hmac_new(passphrase, data, sha1).digest()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Live OpenBridge traffic harness for HBlink3')
|
||||
ap.add_argument('--server-ip', default='127.0.0.1')
|
||||
ap.add_argument('--obp-port', type=int, required=True, help="the OBP system's listen PORT")
|
||||
ap.add_argument('--bind-port', type=int, required=True, help="must equal that OBP's TARGET_PORT")
|
||||
ap.add_argument('--passphrase', default='password')
|
||||
ap.add_argument('--peer', type=int, default=3120, help='source network id (DMRD peer field)')
|
||||
ap.add_argument('--src', type=int, default=3120001, help='RF source (subscriber) id')
|
||||
ap.add_argument('--tgid', type=int, default=8)
|
||||
ap.add_argument('--streams', type=int, default=3, help='concurrent overlapping streams (OBP is not slot-bound)')
|
||||
ap.add_argument('--calls', type=int, default=3, help='number of call rounds')
|
||||
ap.add_argument('--duration', type=float, default=2.0, help='seconds of audio per call')
|
||||
ap.add_argument('--gap', type=float, default=1.5, help='idle seconds between rounds')
|
||||
ap.add_argument('--frame-ms', type=float, default=60.0)
|
||||
ap.add_argument('--no-term', action='store_true', help='lost tail: stop with no terminator')
|
||||
ap.add_argument('--tail-vseq', type=int, default=None, choices=range(6))
|
||||
args = ap.parse_args()
|
||||
|
||||
passphrase = args.passphrase.encode()
|
||||
dst = (args.server_ip, args.obp_port)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('127.0.0.1', args.bind_port)) # source addr must match OBP TARGET_SOCK
|
||||
sock.setblocking(False)
|
||||
print('[obp] sending to OBP %s from :%d (passphrase %r)' % (dst, args.bind_port, args.passphrase), flush=True)
|
||||
|
||||
superframes = max(1, round(args.duration / (6 * args.frame_ms / 1000.0)))
|
||||
terminate = not args.no_term
|
||||
egress = [0]
|
||||
|
||||
def drain():
|
||||
try:
|
||||
while True:
|
||||
sock.recv(2048)
|
||||
egress[0] += 1
|
||||
except BlockingIOError:
|
||||
pass
|
||||
|
||||
sid = random.randint(0x10000000, 0xF0000000)
|
||||
for c in range(args.calls):
|
||||
streams = []
|
||||
for k in range(args.streams):
|
||||
sid += 1
|
||||
tail = args.tail_vseq if args.tail_vseq is not None else random.randint(0, 5)
|
||||
streams.append({'sid': sid, 'src': args.src + k, 'seq': 0, 'tail': tail})
|
||||
print('[obp] round %d/%d: %d concurrent streams on TGID %d %s'
|
||||
% (c + 1, args.calls, args.streams, args.tgid,
|
||||
'TERMINATED' if terminate else 'LOST TAIL'), flush=True)
|
||||
|
||||
def tx(st, ft, vseq):
|
||||
sock.sendto(mk_obp(st['seq'], st['src'], args.tgid, args.peer, ft, vseq, st['sid'], passphrase), dst)
|
||||
st['seq'] = (st['seq'] + 1) & 0xFF
|
||||
|
||||
for st in streams:
|
||||
tx(st, _FT_DATA_SYNC, _VHEAD)
|
||||
for sf in range(superframes):
|
||||
for vseq in range(6):
|
||||
for st in streams:
|
||||
if terminate or sf < superframes - 1 or vseq <= st['tail']:
|
||||
tx(st, _FT_VOICE, vseq)
|
||||
time.sleep(args.frame_ms / 1000.0)
|
||||
drain()
|
||||
if terminate:
|
||||
for st in streams:
|
||||
tx(st, _FT_DATA_SYNC, _VTERM)
|
||||
if c + 1 < args.calls:
|
||||
time.sleep(args.gap)
|
||||
drain()
|
||||
drain()
|
||||
print('[obp] done (received %d egress frames back)' % egress[0], flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Regression tests for HBSYSTEM.dmrd_acl_check(), the ACL helper extracted from
|
||||
# the server and outbound receive paths in hblink.py during the 2026 cleanup.
|
||||
# Pins both the PERMIT/DENY + per-slot matching contract and the "log each
|
||||
# dropped stream only once per slot" dedup behavior.
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import random
|
||||
|
||||
import const
|
||||
import config
|
||||
import hblink
|
||||
from dmr_utils3.utils import bytes_3, bytes_4
|
||||
|
||||
|
||||
# Reference implementation of the ORIGINAL linear ACL semantics, used to prove
|
||||
# the bisect-based acl_check is behavior-identical.
|
||||
def _ref_build(_acl, _max):
|
||||
if not _acl:
|
||||
return (True, [(const.ID_MIN, _max)])
|
||||
action = (_acl.split(':')[0] == 'PERMIT')
|
||||
ranges = []
|
||||
for entry in _acl.split(':')[1].split(','):
|
||||
if entry == 'ALL':
|
||||
return (action, [(const.ID_MIN, _max)])
|
||||
elif '-' in entry:
|
||||
s, e = entry.split('-')
|
||||
ranges.append((int(s), int(e)))
|
||||
else:
|
||||
ranges.append((int(entry), int(entry)))
|
||||
return (action, ranges)
|
||||
|
||||
|
||||
def _ref_check(_id, _ref):
|
||||
action, ranges = _ref
|
||||
for s, e in ranges:
|
||||
if s <= _id <= e:
|
||||
return action
|
||||
return not action
|
||||
|
||||
|
||||
def _acl(_str):
|
||||
return config.acl_build(_str, const.ID_MAX)
|
||||
|
||||
|
||||
def _aclset(use, sub='PERMIT:ALL', tg1='PERMIT:ALL', tg2='PERMIT:ALL'):
|
||||
return {'USE_ACL': use, 'SUB_ACL': _acl(sub),
|
||||
'TG1_ACL': _acl(tg1), 'TG2_ACL': _acl(tg2)}
|
||||
|
||||
|
||||
class _FakeSystem:
|
||||
# Borrow the real method under test, unbound, onto a minimal host object so
|
||||
# we don't have to stand up a full Twisted DatagramProtocol.
|
||||
dmrd_acl_check = hblink.HBSYSTEM.dmrd_acl_check
|
||||
|
||||
def __init__(self, global_acl, system_acl):
|
||||
self._CONFIG = {'GLOBAL': global_acl}
|
||||
self._config = system_acl
|
||||
self._system = 'TEST'
|
||||
self._laststrid = {1: b'', 2: b''}
|
||||
|
||||
|
||||
SID = b'\x11\x22\x33\x44'
|
||||
SID2 = b'\xaa\xbb\xcc\xdd'
|
||||
|
||||
|
||||
class TestAclMatching(unittest.TestCase):
|
||||
def test_permit_all_allows_both_slots(self):
|
||||
sysobj = _FakeSystem(_aclset(True), _aclset(True))
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID))
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 2, SID))
|
||||
|
||||
def test_global_subscriber_deny_drops_only_matching_sub(self):
|
||||
sysobj = _FakeSystem(_aclset(True, sub='DENY:1'), _aclset(True))
|
||||
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(2), bytes_3(9), 1, SID))
|
||||
|
||||
def test_global_tg1_acl_is_slot1_only(self):
|
||||
sysobj = _FakeSystem(_aclset(True, tg1='DENY:9'), _aclset(True))
|
||||
# Denied on TS1...
|
||||
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID))
|
||||
# ...but TS2 is governed by TG2_ACL (PERMIT:ALL here), so it passes
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 2, SID))
|
||||
|
||||
def test_global_tg2_acl_is_slot2_only(self):
|
||||
sysobj = _FakeSystem(_aclset(True, tg2='DENY:9'), _aclset(True))
|
||||
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 2, SID))
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID))
|
||||
|
||||
def test_system_acl_applies_when_global_disabled(self):
|
||||
sysobj = _FakeSystem(_aclset(False, sub='DENY:1'),
|
||||
_aclset(True, sub='DENY:5'))
|
||||
# Global is off so its DENY:1 is ignored; system DENY:5 still applies
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
|
||||
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(5), bytes_3(9), 1, SID))
|
||||
|
||||
def test_both_disabled_allows_everything(self):
|
||||
sysobj = _FakeSystem(_aclset(False, sub='DENY:1'),
|
||||
_aclset(False, sub='DENY:1'))
|
||||
self.assertFalse(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
|
||||
|
||||
|
||||
class TestAclDedup(unittest.TestCase):
|
||||
def test_allow_does_not_touch_laststrid(self):
|
||||
sysobj = _FakeSystem(_aclset(True), _aclset(True))
|
||||
sysobj.dmrd_acl_check(bytes_3(1234), bytes_3(9), 1, SID)
|
||||
self.assertEqual(sysobj._laststrid[1], b'')
|
||||
|
||||
def test_drop_records_stream_id_for_its_slot(self):
|
||||
sysobj = _FakeSystem(_aclset(True, sub='DENY:1'), _aclset(True))
|
||||
sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 2, SID)
|
||||
self.assertEqual(sysobj._laststrid[2], SID)
|
||||
self.assertEqual(sysobj._laststrid[1], b'')
|
||||
|
||||
def test_same_stream_logs_once_new_stream_logs_again(self):
|
||||
sysobj = _FakeSystem(_aclset(True, sub='DENY:1'), _aclset(True))
|
||||
with self.assertLogs('hblink', level='INFO') as cm:
|
||||
# Same dropped stream three times -> a single log line
|
||||
for _ in range(3):
|
||||
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID))
|
||||
# A new stream id -> logs again
|
||||
self.assertTrue(sysobj.dmrd_acl_check(bytes_3(1), bytes_3(9), 1, SID2))
|
||||
drops = [m for m in cm.output if 'CALL DROPPED' in m]
|
||||
self.assertEqual(len(drops), 2)
|
||||
|
||||
|
||||
class TestAclCheckEquivalence(unittest.TestCase):
|
||||
# The bisect-based acl_check must match the original linear semantics for
|
||||
# every ACL shape: permit/deny, singles, ranges, overlapping/adjacent
|
||||
# ranges, ALL, and large enumerated lists.
|
||||
SPECS = [
|
||||
'PERMIT:ALL',
|
||||
'DENY:1',
|
||||
'DENY:1-100',
|
||||
'PERMIT:3100-3200',
|
||||
'DENY:9,10,3129',
|
||||
'PERMIT:1-5,3-10,100', # overlapping ranges
|
||||
'DENY:1-10,11-20', # adjacent ranges
|
||||
'PERMIT:50,40,30,20,10', # unsorted singles
|
||||
'DENY:5-5', # degenerate range == single
|
||||
]
|
||||
|
||||
def test_matches_linear_reference(self):
|
||||
probe = [0, 1, 2, 5, 9, 10, 11, 20, 50, 100, 101, 3099, 3129, 3200, 3201,
|
||||
const.ID_MAX, const.ID_MAX + 1]
|
||||
for spec in self.SPECS:
|
||||
built = config.acl_build(spec, const.ID_MAX)
|
||||
ref = _ref_build(spec, const.ID_MAX)
|
||||
for i in probe:
|
||||
self.assertEqual(
|
||||
hblink.acl_check(bytes_4(i), built),
|
||||
_ref_check(i, ref),
|
||||
'spec={!r} id={}'.format(spec, i))
|
||||
|
||||
def test_large_enumerated_acl_matches_reference(self):
|
||||
random.seed(42)
|
||||
ids = random.sample(range(1, const.ID_MAX), 800)
|
||||
spec = 'DENY:' + ','.join(str(i) for i in ids)
|
||||
built = config.acl_build(spec, const.ID_MAX)
|
||||
ref = _ref_build(spec, const.ID_MAX)
|
||||
denied = set(ids)
|
||||
for i in ids[:200] + random.sample(range(1, const.ID_MAX), 200):
|
||||
self.assertEqual(hblink.acl_check(bytes_4(i), built), _ref_check(i, ref))
|
||||
# spot check the actual intent too: listed ids are denied
|
||||
self.assertEqual(hblink.acl_check(bytes_4(i), built), i not in denied)
|
||||
|
||||
def test_ranges_are_merged_and_disjoint(self):
|
||||
action, singles, starts, ends = config.acl_build('DENY:1-10,5-20,22', const.ID_MAX)
|
||||
self.assertEqual(starts, (1,))
|
||||
self.assertEqual(ends, (20,))
|
||||
self.assertIn(22, singles)
|
||||
|
||||
def test_blank_acl_permits_all(self):
|
||||
built = config.acl_build('', const.ID_MAX)
|
||||
self.assertTrue(hblink.acl_check(bytes_4(1), built))
|
||||
self.assertTrue(hblink.acl_check(bytes_4(const.ID_MAX), built))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Golden-master test: replays the fixed scenarios in baseline_scenarios.py
|
||||
# through the current routing core and asserts the emitted packets match the
|
||||
# frozen baseline in baseline_emissions.json byte-for-byte.
|
||||
#
|
||||
# This is the safety net for the routing refactor: regenerate the baseline ONLY
|
||||
# from known-good code (python tests/baseline_scenarios.py shows current output;
|
||||
# the file was frozen from the pre-refactor code). After the forward_group
|
||||
# extraction and the later unification, this test must still pass unchanged.
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
import baseline_scenarios
|
||||
|
||||
BASELINE = os.path.join(_HERE, 'baseline_emissions.json')
|
||||
|
||||
|
||||
class TestRoutingBaseline(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
with open(BASELINE) as f:
|
||||
cls.expected = json.load(f)
|
||||
|
||||
def test_scenarios_match_frozen_baseline(self):
|
||||
# Same set of scenarios as when the baseline was frozen.
|
||||
self.assertEqual(set(baseline_scenarios.SCENARIOS),
|
||||
set(self.expected),
|
||||
'scenario set drifted from the frozen baseline')
|
||||
|
||||
for name in sorted(baseline_scenarios.SCENARIOS):
|
||||
with self.subTest(scenario=name):
|
||||
got = baseline_scenarios.SCENARIOS[name]()
|
||||
# JSON round-trips tuples to lists; normalize for comparison.
|
||||
got = [[s, h] for (s, h) in got]
|
||||
self.assertEqual(got, self.expected[name],
|
||||
'emissions changed for scenario %r' % name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Regression tests for call-END emission on HBP (server/outbound) systems.
|
||||
#
|
||||
# Background: a slot's "stream has ended" state used to be inferred from
|
||||
# RX_TYPE/TX_TYPE == HBPF_SLT_VTERM. But those fields hold the last frame's
|
||||
# _dtype_vseq, which for voice bursts cycles 0..5 and so collides with
|
||||
# HBPF_SLT_VTERM (== 2). Any stream whose last received burst was vseq 2 looked
|
||||
# "already terminated", so its END was never emitted -- not on a clean
|
||||
# terminator, not by the stream-timeout trimmer, not by new-stream supersession.
|
||||
# The fix replaces that inference with explicit RX_TERMINATED / TX_TERMINATED
|
||||
# flags. These tests pin the behavior, with the vseq-2 case as the key guard.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from dmr_utils3.utils import bytes_3, bytes_4
|
||||
import bridge
|
||||
import harness
|
||||
|
||||
|
||||
def _member(system, ts, tgid):
|
||||
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
|
||||
|
||||
|
||||
class _Report:
|
||||
"""Captures the newline-delimited bridge events a system would emit."""
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def send_bridge_event(self, data):
|
||||
self.events.append(data.decode())
|
||||
|
||||
|
||||
def _world():
|
||||
"""A two-HBP-system world with reporting enabled and captured."""
|
||||
w = harness.World({'B': [_member('SERVER-1', 1, 3100), _member('REPEATER-1', 1, 3100)]})
|
||||
w.CONFIG['REPORTS']['REPORT'] = True
|
||||
rpt = _Report()
|
||||
for sysobj in bridge.systems.values():
|
||||
sysobj._report = rpt
|
||||
return w, rpt
|
||||
|
||||
|
||||
def _ends(rpt, ct='GROUP VOICE', direction='RX'):
|
||||
prefix = '{},END,{},'.format(ct, direction)
|
||||
return [e for e in rpt.events if e.startswith(prefix)]
|
||||
|
||||
|
||||
# Common call parameters for SERVER-1 sourcing on TS1/TGID3100.
|
||||
_KW = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000), slot=1)
|
||||
|
||||
|
||||
class TestGroupEnd(unittest.TestCase):
|
||||
|
||||
def test_clean_call_emits_end(self):
|
||||
"""A properly terminated call (ends on a full superframe) emits an END."""
|
||||
w, rpt = _world()
|
||||
sid = b'\x00\x00\x00\x01'
|
||||
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
|
||||
for burst in range(6): # full superframe, last burst vseq 5
|
||||
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
|
||||
w.feed_group_terminator('SERVER-1', stream_id=sid, **_KW)
|
||||
self.assertEqual(len(_ends(rpt)), 1)
|
||||
|
||||
def test_lost_tail_vseq2_times_out_with_end(self):
|
||||
"""The regression: a dropped-RF call whose last burst is vseq 2 (== VTERM)
|
||||
must still get an END from the stream-timeout trimmer."""
|
||||
w, rpt = _world()
|
||||
sid = b'\x00\x00\x00\x02'
|
||||
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
|
||||
for burst in (0, 1, 2): # lost tail, last burst vseq 2, no VTERM
|
||||
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
|
||||
self.assertEqual(_ends(rpt), []) # nothing yet -- call still "open"
|
||||
w.clock.tick(bridge.STREAM_TIMEOUT + 1) # past the no-audio timeout
|
||||
bridge.stream_trimmer_loop()
|
||||
self.assertEqual(len(_ends(rpt)), 1)
|
||||
|
||||
def test_lost_tail_vseq5_times_out_with_end(self):
|
||||
"""Control: a lost tail ending on vseq 5 also times out with an END."""
|
||||
w, rpt = _world()
|
||||
sid = b'\x00\x00\x00\x03'
|
||||
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
|
||||
for burst in (0, 1, 2, 3, 4, 5):
|
||||
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
|
||||
w.clock.tick(bridge.STREAM_TIMEOUT + 1)
|
||||
bridge.stream_trimmer_loop()
|
||||
self.assertEqual(len(_ends(rpt)), 1)
|
||||
|
||||
def test_supersession_emits_end_for_stuck_vseq2_stream(self):
|
||||
"""A new stream taking the slot while a vseq-2 lost tail is stuck must
|
||||
synthesize the prior call's END before starting the new one."""
|
||||
w, rpt = _world()
|
||||
old = b'\x00\x00\x00\x04'
|
||||
w.feed_group_header('SERVER-1', stream_id=old, **_KW)
|
||||
for burst in (0, 1, 2): # stuck lost tail, last burst vseq 2
|
||||
w.feed_group_burst('SERVER-1', burst, stream_id=old, **_KW)
|
||||
self.assertEqual(_ends(rpt), [])
|
||||
# A different subscriber keys up on the same slot -> supersession.
|
||||
new = b'\x00\x00\x00\x05'
|
||||
kw2 = dict(_KW, rf_src=bytes_3(312999))
|
||||
w.clock.tick(bridge.STREAM_TO + 0.1) # past contention window
|
||||
w.feed_group_header('SERVER-1', stream_id=new, **kw2)
|
||||
self.assertEqual(len(_ends(rpt)), 1) # the stuck call was ended
|
||||
|
||||
def test_no_duplicate_end_on_clean_call(self):
|
||||
"""A cleanly terminated call must not also get a timeout/supersession END."""
|
||||
w, rpt = _world()
|
||||
sid = b'\x00\x00\x00\x06'
|
||||
w.feed_group_header('SERVER-1', stream_id=sid, **_KW)
|
||||
for burst in range(6):
|
||||
w.feed_group_burst('SERVER-1', burst, stream_id=sid, **_KW)
|
||||
w.feed_group_terminator('SERVER-1', stream_id=sid, **_KW)
|
||||
w.clock.tick(bridge.STREAM_TIMEOUT + 1)
|
||||
bridge.stream_trimmer_loop()
|
||||
self.assertEqual(len(_ends(rpt)), 1) # exactly one, not two
|
||||
|
||||
|
||||
class TestCollisionLogging(unittest.TestCase):
|
||||
|
||||
def test_collision_logged_once_per_stream(self):
|
||||
"""A contending stream is reported once, not once per colliding frame."""
|
||||
w, _ = _world()
|
||||
owner = b'\x00\x00\x00\x10'
|
||||
w.feed_group_header('SERVER-1', stream_id=owner, **_KW)
|
||||
w.feed_group_burst('SERVER-1', 1, stream_id=owner, **_KW)
|
||||
collider = dict(_KW, rf_src=bytes_3(312999)) # different subscriber
|
||||
cid = b'\x00\x00\x00\x11'
|
||||
with self.assertLogs('bridge', level='WARNING') as cm:
|
||||
w.feed_group_header('SERVER-1', stream_id=cid, **collider)
|
||||
w.feed_group_burst('SERVER-1', 1, stream_id=cid, **collider)
|
||||
w.feed_group_burst('SERVER-1', 2, stream_id=cid, **collider)
|
||||
hits = [m for m in cm.output if 'collided with existing call' in m]
|
||||
self.assertEqual(len(hits), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Smoke / characterization tests proving the frame-replay harness actually
|
||||
# drives the real routing core and captures sensible emissions. These also
|
||||
# document current behavior that the planned unification must preserve.
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE)) # repo root
|
||||
sys.path.insert(0, _HERE) # tests/ dir, for 'import harness'
|
||||
|
||||
from dmr_utils3.utils import bytes_3, bytes_4
|
||||
import harness
|
||||
|
||||
|
||||
# The sample config defines SERVER-1 (HBP master), REPEATER-1 (HBP peer) and
|
||||
# OBP-1 (OpenBridge). Build bridges over them on TS1 / TGID 3100.
|
||||
def _bridge_spec(systems):
|
||||
return {
|
||||
'TEST': [
|
||||
{'SYSTEM': s, 'TS': 1, 'TGID': 3100, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
|
||||
for s in systems
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _tgid_of(pkt):
|
||||
return pkt[8:11]
|
||||
|
||||
|
||||
def _stream_of(pkt):
|
||||
return pkt[16:20]
|
||||
|
||||
|
||||
class TestHarnessDrivesRoutingCore(unittest.TestCase):
|
||||
def test_group_call_hbp_to_hbp_is_forwarded(self):
|
||||
w = harness.World(_bridge_spec(['SERVER-1', 'REPEATER-1']))
|
||||
sid = b'\x00\x00\x00\x01'
|
||||
kw = dict(rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=sid)
|
||||
w.feed_group_header('SERVER-1', **kw)
|
||||
w.feed_group_terminator('SERVER-1', **kw)
|
||||
|
||||
# The call should have been bridged to REPEATER-1 (and never echoed back
|
||||
# to the source system).
|
||||
to_repeater = w.emitted_to('REPEATER-1')
|
||||
self.assertTrue(to_repeater, 'call was not bridged to the peer system')
|
||||
self.assertEqual(w.emitted_to('SERVER-1'), [])
|
||||
# Destination TGID preserved on the bridged copy.
|
||||
self.assertEqual(_tgid_of(to_repeater[0]), bytes_3(3100))
|
||||
self.assertEqual(_stream_of(to_repeater[0]), sid)
|
||||
|
||||
def test_group_call_hbp_to_obp_is_forwarded(self):
|
||||
w = harness.World(_bridge_spec(['SERVER-1', 'OBP-1']))
|
||||
sid = b'\x00\x00\x00\x02'
|
||||
kw = dict(rf_src=bytes_3(312000), dst=bytes_3(3100),
|
||||
peer=bytes_4(312000), slot=1, stream_id=sid)
|
||||
w.feed_group_header('SERVER-1', **kw)
|
||||
w.feed_group_terminator('SERVER-1', **kw)
|
||||
self.assertTrue(w.emitted_to('OBP-1'), 'call was not bridged to OBP')
|
||||
|
||||
def test_two_concurrent_obp_source_streams_both_forward(self):
|
||||
# THE case the naive "OBP-as-TS1" unification would break: two different
|
||||
# talkgroups arriving concurrently from an OBP trunk must BOTH bridge out.
|
||||
# TGID 3100 routes to SERVER-1, TGID 3200 routes to REPEATER-1, so the two
|
||||
# interleaved OBP streams have independent destinations and both forward.
|
||||
spec = {
|
||||
'B3100': [
|
||||
{'SYSTEM': 'OBP-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
|
||||
{'SYSTEM': 'SERVER-1', 'TS': 1, 'TGID': 3100, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
|
||||
],
|
||||
'B3200': [
|
||||
{'SYSTEM': 'OBP-1', 'TS': 1, 'TGID': 3200, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
|
||||
{'SYSTEM': 'REPEATER-1', 'TS': 1, 'TGID': 3200, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []},
|
||||
],
|
||||
}
|
||||
w = harness.World(spec)
|
||||
a = b'\x00\x00\x0A\x0A'
|
||||
b = b'\x00\x00\x0B\x0B'
|
||||
# Interleave the two streams' headers -- the OBP STATUS dict must hold both.
|
||||
w.feed_group_header('OBP-1', rf_src=bytes_3(1111), dst=bytes_3(3100),
|
||||
peer=bytes_4(3129100), slot=1, stream_id=a)
|
||||
w.feed_group_header('OBP-1', rf_src=bytes_3(2222), dst=bytes_3(3200),
|
||||
peer=bytes_4(3129100), slot=1, stream_id=b)
|
||||
|
||||
src = bridge_status('OBP-1')
|
||||
self.assertIn(a, src, 'first concurrent OBP stream not tracked')
|
||||
self.assertIn(b, src, 'second concurrent OBP stream not tracked')
|
||||
# Each stream reached its own destination, concurrently.
|
||||
self.assertEqual([_stream_of(p) for p in w.emitted_to('SERVER-1')], [a])
|
||||
self.assertEqual([_stream_of(p) for p in w.emitted_to('REPEATER-1')], [b])
|
||||
|
||||
def test_clock_is_controllable(self):
|
||||
w = harness.World(_bridge_spec(['SERVER-1', 'REPEATER-1']))
|
||||
t0 = w.clock()
|
||||
w.clock.tick(5.0)
|
||||
self.assertEqual(w.clock() - t0, 5.0)
|
||||
|
||||
|
||||
def bridge_status(system):
|
||||
import bridge
|
||||
return bridge.systems[system].STATUS
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Regression tests for the Link Control helpers extracted from bridge.py during
|
||||
# the 2026 cleanup: gen_lcs() and embed_lc(). These pin the behavior that all
|
||||
# four group-routing paths depend on, since live OBP/HBP audio cannot be
|
||||
# reproduced in a test environment.
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from bitarray import bitarray
|
||||
from dmr_utils3 import bptc
|
||||
|
||||
import bridge
|
||||
from const import (
|
||||
HBPF_VOICE, HBPF_DATA_SYNC, HBPF_SLT_VHEAD, HBPF_SLT_VTERM, LC_OPT,
|
||||
)
|
||||
|
||||
|
||||
def _bits(_b):
|
||||
ba = bitarray(endian='big')
|
||||
ba.frombytes(_b)
|
||||
return ba
|
||||
|
||||
|
||||
# A representative destination LC: 3 option bytes + 3 TGID bytes + 3 source bytes
|
||||
DST_LC = LC_OPT + b'\x00\x00\x09' + b'\x30\x40\x50'
|
||||
# A fixed, non-trivial 33-byte DMR payload (264 bits)
|
||||
DMRPKT = bytes((i * 7 + 3) & 0xFF for i in range(33))
|
||||
|
||||
|
||||
class TestGenLcs(unittest.TestCase):
|
||||
def test_returns_header_terminator_embedded_in_order(self):
|
||||
h, t, e = bridge.gen_lcs(DST_LC)
|
||||
self.assertEqual(h, bptc.encode_header_lc(DST_LC))
|
||||
self.assertEqual(t, bptc.encode_terminator_lc(DST_LC))
|
||||
self.assertEqual(e, bptc.encode_emblc(DST_LC))
|
||||
|
||||
def test_embedded_lc_has_bursts_1_through_4(self):
|
||||
_, _, e = bridge.gen_lcs(DST_LC)
|
||||
for burst in (1, 2, 3, 4):
|
||||
self.assertEqual(len(e[burst]), 32,
|
||||
'embedded LC burst {} must be 32 bits'.format(burst))
|
||||
|
||||
|
||||
class TestEmbedLc(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.h, self.t, self.e = bridge.gen_lcs(DST_LC)
|
||||
|
||||
def test_voice_header_injects_full_lc_and_preserves_voice(self):
|
||||
out = _bits(bridge.embed_lc(DMRPKT, HBPF_DATA_SYNC, HBPF_SLT_VHEAD,
|
||||
self.h, self.t, self.e))
|
||||
# Full LC replaces the outer bits; the middle voice/sync bits survive
|
||||
self.assertEqual(out[0:98], self.h[0:98])
|
||||
self.assertEqual(out[98:166], _bits(DMRPKT)[98:166])
|
||||
self.assertEqual(out[166:], self.h[98:197])
|
||||
|
||||
def test_voice_terminator_injects_full_lc_and_preserves_voice(self):
|
||||
out = _bits(bridge.embed_lc(DMRPKT, HBPF_DATA_SYNC, HBPF_SLT_VTERM,
|
||||
self.h, self.t, self.e))
|
||||
self.assertEqual(out[0:98], self.t[0:98])
|
||||
self.assertEqual(out[98:166], _bits(DMRPKT)[98:166])
|
||||
self.assertEqual(out[166:], self.t[98:197])
|
||||
|
||||
def test_burst_injects_embedded_lc_and_preserves_voice(self):
|
||||
# dtype 1-4 with a non-DATA_SYNC frame type takes the embedded-LC path
|
||||
for burst in (1, 2, 3, 4):
|
||||
out = _bits(bridge.embed_lc(DMRPKT, HBPF_VOICE, burst,
|
||||
self.h, self.t, self.e))
|
||||
self.assertEqual(out[0:116], _bits(DMRPKT)[0:116],
|
||||
'burst {}: leading voice bits changed'.format(burst))
|
||||
self.assertEqual(out[116:148], self.e[burst],
|
||||
'burst {}: embedded LC not injected'.format(burst))
|
||||
self.assertEqual(out[148:264], _bits(DMRPKT)[148:264],
|
||||
'burst {}: trailing voice bits changed'.format(burst))
|
||||
|
||||
def test_other_frames_pass_through_unchanged(self):
|
||||
# dtype 0 (and 5) are neither full-LC nor embedded-LC frames
|
||||
for dtype in (0, 5):
|
||||
self.assertEqual(
|
||||
bridge.embed_lc(DMRPKT, HBPF_VOICE, dtype, self.h, self.t, self.e),
|
||||
DMRPKT,
|
||||
'dtype {} should pass through unchanged'.format(dtype))
|
||||
|
||||
def test_is_pure_does_not_mutate_input(self):
|
||||
src = bytearray(DMRPKT)
|
||||
bridge.embed_lc(bytes(src), HBPF_DATA_SYNC, HBPF_SLT_VHEAD,
|
||||
self.h, self.t, self.e)
|
||||
self.assertEqual(bytes(src), DMRPKT)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Tests for expand_obp_bridges(): the per-OBP TGID<->bridge table (rules.OBP_BRIDGES)
|
||||
# that replaces inline OpenBridge bridge membership. Covers expansion into synthetic
|
||||
# members, the TS default + override, and the load-time validation (ingress-fork
|
||||
# ERROR, inter-OBP renumber WARNING, inline-OBP-member ERROR, non-OBP system ERROR).
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, _ROOT)
|
||||
sys.path.insert(0, os.path.join(_ROOT, 'tools'))
|
||||
|
||||
import bridge
|
||||
import migrate_obp_rules
|
||||
|
||||
|
||||
def _member(system, ts, tgid):
|
||||
# A plain inline (non-OBP) bridge member, pre-make_bridges (raw int TGID).
|
||||
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
|
||||
|
||||
|
||||
class TestExpandOBPBridges(unittest.TestCase):
|
||||
def setUp(self):
|
||||
bridge.CONFIG = {'SYSTEMS': {
|
||||
'VESTA_OBP': {'MODE': 'OPENBRIDGE'},
|
||||
'CC_OBP': {'MODE': 'OPENBRIDGE'},
|
||||
'NEIGHBOR': {'MODE': 'OPENBRIDGE'},
|
||||
'REPEATERS': {'MODE': 'SERVER'},
|
||||
}}
|
||||
|
||||
def test_noop_without_obp_table(self):
|
||||
bridges = {'B1': [_member('REPEATERS', 1, 2)]}
|
||||
out = bridge.expand_obp_bridges(bridges, {})
|
||||
self.assertEqual(len(out['B1']), 1)
|
||||
self.assertEqual(out['B1'][0]['SYSTEM'], 'REPEATERS')
|
||||
|
||||
def test_expands_and_appends_synthetic_member(self):
|
||||
bridges = {'B1': [_member('REPEATERS', 1, 2)]}
|
||||
bridge.expand_obp_bridges(bridges, {'VESTA_OBP': {'B1': 2}})
|
||||
self.assertEqual(len(bridges['B1']), 2) # appended alongside REPEATERS
|
||||
obp = bridges['B1'][1]
|
||||
self.assertEqual(obp['SYSTEM'], 'VESTA_OBP')
|
||||
self.assertEqual(obp['TGID'], 2)
|
||||
self.assertEqual(obp['TS'], 1) # default
|
||||
self.assertTrue(obp['ACTIVE'])
|
||||
self.assertEqual(obp['TO_TYPE'], 'NONE') # triggers hard-wired inert
|
||||
self.assertEqual((obp['ON'], obp['OFF'], obp['RESET']), ([], [], []))
|
||||
|
||||
def test_ts_override_tuple(self):
|
||||
bridges = {}
|
||||
bridge.expand_obp_bridges(bridges, {'VESTA_OBP': {'KS-STATEWIDE': (3120, 2)}})
|
||||
m = bridges['KS-STATEWIDE'][0]
|
||||
self.assertEqual((m['TGID'], m['TS']), (3120, 2))
|
||||
|
||||
def test_creates_obp_only_bridge(self):
|
||||
bridges = {}
|
||||
bridge.expand_obp_bridges(bridges, {'VESTA_OBP': {'TRANSIT': 31}})
|
||||
self.assertIn('TRANSIT', bridges)
|
||||
self.assertEqual(bridges['TRANSIT'][0]['SYSTEM'], 'VESTA_OBP')
|
||||
|
||||
def test_ingress_fork_is_error(self):
|
||||
# One TGID mapped to two bridges on the SAME OBP -> stream duplication.
|
||||
with self.assertRaises(SystemExit):
|
||||
bridge.expand_obp_bridges({}, {'VESTA_OBP': {'B1': 2, 'B2': 2}})
|
||||
|
||||
def test_unknown_obp_system_is_error(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
bridge.expand_obp_bridges({}, {'NOPE': {'B1': 2}})
|
||||
|
||||
def test_non_openbridge_system_is_error(self):
|
||||
# REPEATERS exists but is a SERVER, not an OPENBRIDGE.
|
||||
with self.assertRaises(SystemExit):
|
||||
bridge.expand_obp_bridges({}, {'REPEATERS': {'B1': 2}})
|
||||
|
||||
def test_inline_obp_member_is_error(self):
|
||||
# Old-style: an OBP system left as an inline BRIDGES member.
|
||||
bridges = {'B1': [_member('VESTA_OBP', 1, 2)]}
|
||||
with self.assertRaises(SystemExit):
|
||||
bridge.expand_obp_bridges(bridges, {})
|
||||
|
||||
def test_same_tgid_across_obps_no_warning(self):
|
||||
# Two OBPs agree on the number -> no renumber, no warning.
|
||||
bridges = {}
|
||||
with self.assertNoLogs(bridge.logger, level='WARNING'):
|
||||
bridge.expand_obp_bridges(bridges, {
|
||||
'VESTA_OBP': {'B1': 2},
|
||||
'CC_OBP': {'B1': 2},
|
||||
})
|
||||
self.assertEqual(len(bridges['B1']), 2)
|
||||
|
||||
def test_renumber_across_obps_warns_but_starts(self):
|
||||
# A bridge carrying different TGIDs on two OBPs: WARNING, not ERROR.
|
||||
bridges = {}
|
||||
with self.assertLogs(bridge.logger, level='WARNING') as cm:
|
||||
bridge.expand_obp_bridges(bridges, {
|
||||
'VESTA_OBP': {'KS-STATEWIDE': 3120},
|
||||
'NEIGHBOR': {'KS-STATEWIDE': 8},
|
||||
})
|
||||
self.assertTrue(any('renumbers TGID' in line for line in cm.output))
|
||||
self.assertEqual(len(bridges['KS-STATEWIDE']), 2) # both still added
|
||||
|
||||
|
||||
class TestMigrateOBPRules(unittest.TestCase):
|
||||
"""The migration tool (tools/migrate_obp_rules.py) must move inline OBP members
|
||||
into OBP_BRIDGES while preserving the exact routing."""
|
||||
|
||||
OBP_SYSTEMS = {'VESTA_OBP', 'CC_OBP'}
|
||||
|
||||
def setUp(self):
|
||||
bridge.CONFIG = {'SYSTEMS': {
|
||||
'VESTA_OBP': {'MODE': 'OPENBRIDGE'},
|
||||
'CC_OBP': {'MODE': 'OPENBRIDGE'},
|
||||
'REPEATERS': {'MODE': 'SERVER'},
|
||||
'IPSC': {'MODE': 'SERVER'},
|
||||
}}
|
||||
|
||||
def _old_bridges(self):
|
||||
# Old inline form: OBP systems mixed in as ordinary members.
|
||||
return {
|
||||
'B2': [_member('REPEATERS', 1, 2), _member('IPSC', 1, 2), _member('VESTA_OBP', 1, 2)],
|
||||
'B9': [_member('REPEATERS', 1, 9), _member('VESTA_OBP', 1, 9), _member('CC_OBP', 1, 9)],
|
||||
'B8': [_member('CC_OBP', 1, 8)], # OBP-only -> should drop from BRIDGES
|
||||
'BSW': [_member('REPEATERS', 2, 3120), _member('VESTA_OBP', 1, 3120)],
|
||||
}
|
||||
|
||||
def _routing_projection(self, bridges, obp_bridges=None):
|
||||
# {(system, ts, tgid_bytes): sorted[bridge names]} -- identity-independent.
|
||||
# obp_bridges=None => old inline path (no expand); a dict => new expanded path.
|
||||
b = copy.deepcopy(bridges)
|
||||
if obp_bridges is not None:
|
||||
b = bridge.expand_obp_bridges(b, obp_bridges)
|
||||
src, _ = bridge.index_bridges(bridge.make_bridges(b))
|
||||
return {k: sorted(hit[0] for hit in v) for k, v in src.items()}
|
||||
|
||||
def test_roundtrip_preserves_routing(self):
|
||||
old = self._old_bridges()
|
||||
# OLD routing: inline OBP members, no OBP_BRIDGES table.
|
||||
want = self._routing_projection(old)
|
||||
|
||||
new_bridges, obp_bridges, moved, errors, warnings = migrate_obp_rules.migrate(
|
||||
copy.deepcopy(old), self.OBP_SYSTEMS)
|
||||
self.assertFalse(errors)
|
||||
# NEW routing: trimmed BRIDGES + expanded OBP_BRIDGES.
|
||||
got = self._routing_projection(new_bridges, obp_bridges)
|
||||
self.assertEqual(got, want)
|
||||
|
||||
def test_moves_and_drops_obp_only_bridge(self):
|
||||
new_bridges, obp_bridges, moved, errors, warnings = migrate_obp_rules.migrate(
|
||||
copy.deepcopy(self._old_bridges()), self.OBP_SYSTEMS)
|
||||
self.assertEqual(len(moved), 5) # VESTA_OBP x3 + CC_OBP x2
|
||||
self.assertNotIn('B8', new_bridges) # OBP-only bridge dropped from BRIDGES
|
||||
self.assertEqual(obp_bridges['CC_OBP']['B8'], 8) # but present in the table
|
||||
self.assertEqual(obp_bridges['VESTA_OBP']['B2'], 2)
|
||||
|
||||
def test_ts_override_becomes_tuple(self):
|
||||
old = {'BSW': [_member('VESTA_OBP', 2, 3120)]} # TS 2 (non-default)
|
||||
_, obp_bridges, _, _, _ = migrate_obp_rules.migrate(old, {'VESTA_OBP'})
|
||||
self.assertEqual(obp_bridges['VESTA_OBP']['BSW'], (3120, 2))
|
||||
|
||||
def test_detects_ingress_fork(self):
|
||||
old = {'B1': [_member('VESTA_OBP', 1, 2)], 'B2': [_member('VESTA_OBP', 1, 2)]}
|
||||
_, _, _, errors, _ = migrate_obp_rules.migrate(old, {'VESTA_OBP'})
|
||||
self.assertTrue(errors)
|
||||
|
||||
def test_detects_renumber(self):
|
||||
old = {'BSW': [_member('VESTA_OBP', 1, 3120), _member('CC_OBP', 1, 8)]}
|
||||
_, _, _, _, warnings = migrate_obp_rules.migrate(old, {'VESTA_OBP', 'CC_OBP'})
|
||||
self.assertTrue(warnings)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Regression test: the router must not forward bridged call traffic to a PEER
|
||||
# system that is not currently logged into its upstream master. Doing so would
|
||||
# fire DMRD at a master that drops it (we're not registered) and falsely show as
|
||||
# an active forward on the dashboard.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from dmr_utils3.utils import bytes_3, bytes_4
|
||||
import bridge
|
||||
import harness
|
||||
|
||||
|
||||
def _member(system, ts, tgid):
|
||||
return {'SYSTEM': system, 'TS': ts, 'TGID': tgid, 'ACTIVE': True,
|
||||
'TIMEOUT': 2, 'TO_TYPE': 'NONE', 'ON': [], 'OFF': [], 'RESET': []}
|
||||
|
||||
_KW = dict(rf_src=bytes_3(312000), dst=bytes_3(3100), peer=bytes_4(312000), slot=1)
|
||||
|
||||
|
||||
class TestPeerEgress(unittest.TestCase):
|
||||
|
||||
def _world(self):
|
||||
# SERVER-1 (source) bridged to REPEATER-1, which is MODE PEER in harness.cfg.
|
||||
return harness.World({'B': [_member('SERVER-1', 1, 3100),
|
||||
_member('REPEATER-1', 1, 3100)]})
|
||||
|
||||
def test_connected_peer_receives_forward(self):
|
||||
w = self._world() # harness marks the peer connected
|
||||
self.assertEqual(bridge.systems['REPEATER-1']._stats['CONNECTION'], 'YES')
|
||||
w.feed_group_header('SERVER-1', stream_id=b'\x00\x00\x00\x40', **_KW)
|
||||
w.feed_group_burst('SERVER-1', 0, stream_id=b'\x00\x00\x00\x40', **_KW)
|
||||
self.assertTrue(w.emitted_to('REPEATER-1'))
|
||||
|
||||
def test_disconnected_peer_target_skipped(self):
|
||||
w = self._world()
|
||||
bridge.systems['REPEATER-1']._stats['CONNECTION'] = 'NO' # not logged in
|
||||
w.feed_group_header('SERVER-1', stream_id=b'\x00\x00\x00\x41', **_KW)
|
||||
for burst in (0, 1, 2):
|
||||
w.feed_group_burst('SERVER-1', burst, stream_id=b'\x00\x00\x00\x41', **_KW)
|
||||
self.assertEqual(w.emitted_to('REPEATER-1'), []) # nothing forwarded
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,426 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Regression tests for the asyncio migration's protocol mechanics -- the parts
|
||||
# the routing harness does not exercise: the server login handshake state
|
||||
# machine, the reporting server's netstring framing, and that the UDP send paths
|
||||
# use transport.sendto(). Driven against mock transports (no real sockets).
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from hashlib import sha256
|
||||
from binascii import a2b_hex as bhex
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(_HERE))
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
import config
|
||||
import hblink
|
||||
|
||||
# Quiet the protocol's INFO chatter (assertLogs elsewhere still works, since it
|
||||
# temporarily lowers the level within its own context).
|
||||
logging.getLogger('hblink').setLevel(logging.CRITICAL)
|
||||
from dmr_utils3.utils import bytes_3, bytes_4
|
||||
from const import RPTL, RPTK, RPTC, RPTACK, MSTNAK, DMRD, HBPF_SLT_VHEAD, HBPF_SLT_VTERM
|
||||
|
||||
CFG = config.build_config(os.path.join(_HERE, 'harness.cfg'))
|
||||
|
||||
|
||||
class MockDatagramTransport:
|
||||
"""Captures sendto(data, addr) like an asyncio DatagramTransport."""
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
def sendto(self, data, addr=None):
|
||||
self.sent.append((bytes(data), addr))
|
||||
|
||||
|
||||
class MockStreamTransport:
|
||||
"""Captures write(data) like an asyncio (TCP) Transport."""
|
||||
def __init__(self, peername=('127.0.0.1', 50001)):
|
||||
self.written = bytearray()
|
||||
self._peername = peername
|
||||
self.closed = False
|
||||
|
||||
def write(self, data):
|
||||
self.written += data
|
||||
|
||||
def get_extra_info(self, key):
|
||||
return self._peername if key == 'peername' else None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class TestServerHandshake(unittest.TestCase):
|
||||
"""Drive a SERVER through RPTL -> RPTK -> RPTC over datagram_received and
|
||||
assert the state machine and responses, exercising the asyncio rename
|
||||
(datagram_received) and transport.sendto path."""
|
||||
|
||||
def setUp(self):
|
||||
self.server = hblink.HBSYSTEM('SERVER-1', CFG, None)
|
||||
self.server.transport = MockDatagramTransport() # bypass connection_made's task
|
||||
self.peer_id = bytes_4(312000)
|
||||
self.addr = ('127.0.0.1', 50000)
|
||||
self.passphrase = CFG['SYSTEMS']['SERVER-1']['PASSPHRASE']
|
||||
|
||||
def test_full_login_exchange(self):
|
||||
m = self.server
|
||||
# 1) Login request -> challenge
|
||||
m.datagram_received(RPTL + self.peer_id, self.addr)
|
||||
self.assertIn(self.peer_id, m._repeaters)
|
||||
self.assertEqual(m._repeaters[self.peer_id]['CONNECTION'], 'CHALLENGE_SENT')
|
||||
self.assertEqual(m.transport.sent[-1][0][:len(RPTACK)], RPTACK)
|
||||
|
||||
# 2) Answer the challenge with the correct hash -> WAITING_CONFIG
|
||||
salt = bytes_4(m._repeaters[self.peer_id]['SALT'])
|
||||
calc = bhex(sha256(salt + self.passphrase).hexdigest())
|
||||
m.datagram_received(RPTK + self.peer_id + calc, self.addr)
|
||||
self.assertEqual(m._repeaters[self.peer_id]['CONNECTION'], 'WAITING_CONFIG')
|
||||
self.assertEqual(m.transport.sent[-1][0], RPTACK + self.peer_id)
|
||||
|
||||
# 3) Send configuration -> connected
|
||||
rptc = RPTC + self.peer_id + b'TEST ' + b'\x00' * 300
|
||||
m.datagram_received(rptc, self.addr)
|
||||
self.assertEqual(m._repeaters[self.peer_id]['CONNECTION'], 'YES')
|
||||
self.assertEqual(m.transport.sent[-1][0], RPTACK + self.peer_id)
|
||||
|
||||
def test_wrong_passphrase_is_rejected(self):
|
||||
m = self.server
|
||||
m.datagram_received(RPTL + self.peer_id, self.addr)
|
||||
bad = bhex(sha256(b'\x00\x00\x00\x00' + b'wrong').hexdigest())
|
||||
m.datagram_received(RPTK + self.peer_id + bad, self.addr)
|
||||
# Peer removed and a NAK sent
|
||||
self.assertNotIn(self.peer_id, m._repeaters)
|
||||
self.assertEqual(m.transport.sent[-1][0][:6], MSTNAK)
|
||||
|
||||
|
||||
class TestPeerEvents(unittest.TestCase):
|
||||
"""The reconciliation sweep emits granular 'peer' connected/disconnected
|
||||
events so the dashboard reflects repeater changes without the full push."""
|
||||
|
||||
def _server(self):
|
||||
captured = []
|
||||
|
||||
class Rep:
|
||||
def send_peer(self, system, radio_id, action, info=None):
|
||||
captured.append((system, radio_id, action, info))
|
||||
|
||||
srv = hblink.HBSYSTEM('SERVER-1', CFG, Rep())
|
||||
srv.transport = MockDatagramTransport()
|
||||
return srv, captured
|
||||
|
||||
def _login(self, srv, radio_id=312000):
|
||||
pid = bytes_4(radio_id)
|
||||
addr = ('127.0.0.1', 50000)
|
||||
passphrase = CFG['SYSTEMS']['SERVER-1']['PASSPHRASE']
|
||||
srv.datagram_received(RPTL + pid, addr)
|
||||
salt = bytes_4(srv._repeaters[pid]['SALT'])
|
||||
calc = bhex(sha256(salt + passphrase).hexdigest())
|
||||
srv.datagram_received(RPTK + pid + calc, addr)
|
||||
srv.datagram_received(RPTC + pid + b'TEST ' + b'\x00' * 300, addr)
|
||||
return pid
|
||||
|
||||
def test_connected_event_on_login(self):
|
||||
srv, cap = self._server()
|
||||
self._login(srv)
|
||||
conn = [c for c in cap if c[2] == 'connected']
|
||||
self.assertEqual(len(conn), 1)
|
||||
self.assertEqual(conn[0][0], 'SERVER-1') # system
|
||||
self.assertEqual(conn[0][1], 312000) # radio_id
|
||||
self.assertIsNotNone(conn[0][3]) # info payload present
|
||||
self.assertEqual(conn[0][3]['RADIO_ID'], 312000)
|
||||
|
||||
def test_no_duplicate_connected_event(self):
|
||||
srv, cap = self._server()
|
||||
self._login(srv)
|
||||
srv.report_peer_deltas() # a later sweep, nothing changed
|
||||
self.assertEqual(len([c for c in cap if c[2] == 'connected']), 1)
|
||||
|
||||
def test_disconnected_event_on_timeout(self):
|
||||
srv, cap = self._server()
|
||||
pid = self._login(srv)
|
||||
srv._repeaters[pid]['LAST_PING'] = 0 # force the ping timeout
|
||||
srv.server_maintenance_loop()
|
||||
disc = [c for c in cap if c[2] == 'disconnected']
|
||||
self.assertEqual(len(disc), 1)
|
||||
self.assertEqual(disc[0][1], 312000)
|
||||
|
||||
|
||||
class TestPingQuality(unittest.TestCase):
|
||||
"""Per-repeater ping-loss %: self-calibrated cadence, decaying, cadence-agnostic."""
|
||||
|
||||
def _server(self):
|
||||
srv = hblink.HBSYSTEM('SERVER-1', CFG, None)
|
||||
srv.transport = MockDatagramTransport()
|
||||
return srv
|
||||
|
||||
def _feed_pings(self, srv, pid, times):
|
||||
# Drive _note_ping with an explicit sequence of arrival timestamps.
|
||||
prev = times[0]
|
||||
srv._note_ping(pid, times[0], 0) # first ping, seeds
|
||||
for t in times[1:]:
|
||||
srv._note_ping(pid, t, prev)
|
||||
prev = t
|
||||
|
||||
def test_clean_pings_zero_loss(self):
|
||||
srv = self._server()
|
||||
pid = bytes_4(312000)
|
||||
self._feed_pings(srv, pid, [i * 10.0 for i in range(30)]) # every 10s, no gaps
|
||||
self.assertEqual(srv._ping_loss_pct(pid, 300.0), 0)
|
||||
|
||||
def test_slower_cadence_is_not_loss(self):
|
||||
srv = self._server()
|
||||
pid = bytes_4(312000)
|
||||
# Pings every 30s (much slower than GLOBAL PING_TIME) must NOT read as loss.
|
||||
self._feed_pings(srv, pid, [i * 30.0 for i in range(20)])
|
||||
self.assertEqual(srv._ping_loss_pct(pid, 600.0), 0)
|
||||
|
||||
def test_dropped_pings_register_loss(self):
|
||||
srv = self._server()
|
||||
pid = bytes_4(312000)
|
||||
# Establish a 10s cadence, then drop roughly 1 of every 3 pings.
|
||||
times = [i * 10.0 for i in range(10)] # clean baseline
|
||||
t = times[-1]
|
||||
for k in range(20):
|
||||
t += 30.0 if k % 3 == 0 else 10.0 # every 3rd interval misses 2 pings
|
||||
times.append(t)
|
||||
self._feed_pings(srv, pid, times)
|
||||
loss = srv._ping_loss_pct(pid, times[-1])
|
||||
self.assertGreater(loss, 10) # clearly lossy
|
||||
self.assertLess(loss, 60)
|
||||
|
||||
def test_loss_registers_then_ages_out(self):
|
||||
win = CFG['GLOBAL']['PING_LOSS_WINDOW'] * 60 # seconds
|
||||
pid = bytes_4(312000)
|
||||
# Clean warmup (calibrates base=10s), then a burst of dropped pings.
|
||||
times = [i * 10.0 for i in range(6)]
|
||||
t = times[-1]
|
||||
for _ in range(5): # 5 drops: 20s gaps = lost pings
|
||||
t += 20.0
|
||||
times.append(t)
|
||||
srv = self._server()
|
||||
self._feed_pings(srv, pid, times)
|
||||
self.assertGreater(srv._ping_loss_pct(pid, times[-1]), 0) # loss registered
|
||||
|
||||
# Same start, then clean pings filling more than the whole window: the old
|
||||
# loss must fall out of the window and the figure return to 0.
|
||||
loss_end = times[-1] # fix the bound BEFORE extending `times`
|
||||
while t < loss_end + win + 30: # (appending inside made times[-1] track t -> infinite loop)
|
||||
t += 10.0
|
||||
times.append(t)
|
||||
srv2 = self._server()
|
||||
self._feed_pings(srv2, pid, times)
|
||||
self.assertEqual(srv2._ping_loss_pct(pid, times[-1]), 0)
|
||||
|
||||
|
||||
class MockWriteTransport:
|
||||
"""Mimics enough of an asyncio Transport for ReportServer._send_json."""
|
||||
def __init__(self, buffer_size=0):
|
||||
self._buffer_size = buffer_size
|
||||
|
||||
def get_write_buffer_size(self):
|
||||
return self._buffer_size
|
||||
|
||||
|
||||
class MockAsyncWriter:
|
||||
"""Mimics enough of an asyncio StreamWriter for ReportServer."""
|
||||
def __init__(self, peername=('127.0.0.1', 51000), closing=False, buffer_size=0):
|
||||
self.written = bytearray()
|
||||
self._peername = peername
|
||||
self._closing = closing
|
||||
self.closed = False
|
||||
self.transport = MockWriteTransport(buffer_size)
|
||||
|
||||
def write(self, data):
|
||||
self.written += data
|
||||
|
||||
def is_closing(self):
|
||||
return self._closing
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
self._closing = True
|
||||
|
||||
def get_extra_info(self, key):
|
||||
# 'socket' returns None so keepalive tuning is skipped in tests.
|
||||
return {'peername': self._peername, 'socket': None}.get(key)
|
||||
|
||||
|
||||
class MockAsyncReader:
|
||||
"""Async reader that yields the given chunks then EOF (b'')."""
|
||||
def __init__(self, chunks=()):
|
||||
self._chunks = list(chunks)
|
||||
|
||||
async def read(self, _n):
|
||||
return self._chunks.pop(0) if self._chunks else b''
|
||||
|
||||
|
||||
class TestReportingNDJSON(unittest.TestCase):
|
||||
# Exercises the asyncio ReportServer: the client ACL, and the write path's
|
||||
# shedding of dead/unresponsive clients (is_closing() or an over-limit write
|
||||
# buffer). Uses mock reader/writer objects -- no real sockets or config.
|
||||
def _server(self):
|
||||
return hblink.ReportServer({})
|
||||
|
||||
def test_send_json_emits_one_terminated_line(self):
|
||||
srv = self._server()
|
||||
w = MockAsyncWriter()
|
||||
srv.clients.append(w)
|
||||
srv._send_json({'type': 'ping', 'n': 1})
|
||||
data = bytes(w.written)
|
||||
self.assertTrue(data.endswith(b'\n'))
|
||||
self.assertEqual(json.loads(data), {'type': 'ping', 'n': 1})
|
||||
self.assertEqual(data.count(b'\n'), 1)
|
||||
|
||||
def test_send_json_sheds_closing_writer(self):
|
||||
srv = self._server()
|
||||
alive, dead = MockAsyncWriter(), MockAsyncWriter(closing=True)
|
||||
srv.clients.extend([alive, dead])
|
||||
srv._send_json({'type': 'ping'})
|
||||
self.assertIn(alive, srv.clients)
|
||||
self.assertNotIn(dead, srv.clients) # is_closing() -> shed
|
||||
self.assertEqual(bytes(dead.written), b'')
|
||||
|
||||
def test_send_json_sheds_unresponsive_writer(self):
|
||||
srv = self._server()
|
||||
stuck = MockAsyncWriter(buffer_size=(2 << 20)) # over the 1 MiB limit
|
||||
srv.clients.append(stuck)
|
||||
srv._send_json({'type': 'ping'})
|
||||
self.assertNotIn(stuck, srv.clients) # not reading -> shed and closed
|
||||
self.assertTrue(stuck.closed)
|
||||
|
||||
def test_disallowed_client_is_closed(self):
|
||||
srv = self._server()
|
||||
w = MockAsyncWriter(peername=('10.9.9.9', 1234)) # not in allowed list
|
||||
asyncio.run(srv._client_connected(MockAsyncReader(), w, allowed=['127.0.0.1']))
|
||||
self.assertTrue(w.closed)
|
||||
self.assertNotIn(w, srv.clients)
|
||||
|
||||
def test_json_systems_omits_secrets(self):
|
||||
view = hblink.json_systems(CFG['SYSTEMS'])
|
||||
for sysview in view.values():
|
||||
self.assertNotIn('PASSPHRASE', sysview)
|
||||
self.assertNotIn('SUB_ACL', sysview)
|
||||
|
||||
|
||||
class TestBridgeStreamEvents(unittest.TestCase):
|
||||
# BridgeReportServer.send_bridge_event() turns the CSV strings the routing
|
||||
# code emits into JSON stream events. Capture _send_json to inspect them.
|
||||
def _server(self):
|
||||
import bridge
|
||||
srv = bridge.BridgeReportServer({})
|
||||
captured = []
|
||||
srv._send_json = captured.append
|
||||
return srv, captured
|
||||
|
||||
def test_start_csv_becomes_json(self):
|
||||
srv, cap = self._server()
|
||||
srv.send_bridge_event(b'GROUP VOICE,START,RX,SERVER-1,123,312000,3120001,1,3100')
|
||||
self.assertEqual(cap[0], {
|
||||
'type': 'stream_start', 'call_type': 'GROUP VOICE', 'action': 'START',
|
||||
'trx': 'RX', 'system': 'SERVER-1', 'stream_id': 123, 'peer': 312000,
|
||||
'src': 3120001, 'slot': 1, 'dst': 3100})
|
||||
|
||||
def test_end_csv_includes_duration(self):
|
||||
srv, cap = self._server()
|
||||
srv.send_bridge_event('GROUP VOICE,END,TX,SERVER-1,123,312000,3120001,2,3100,4.20')
|
||||
self.assertEqual(cap[0]['type'], 'stream_end')
|
||||
self.assertEqual(cap[0]['action'], 'END')
|
||||
self.assertEqual(cap[0]['slot'], 2)
|
||||
self.assertEqual(cap[0]['duration'], 4.20)
|
||||
|
||||
|
||||
class TestTransportSend(unittest.TestCase):
|
||||
def test_openbridge_send_uses_sendto_to_target(self):
|
||||
obp = hblink.OPENBRIDGE('OBP-1', CFG, None)
|
||||
obp.transport = MockDatagramTransport()
|
||||
# 20-byte DMRD header + payload; send_system rewrites peer id and appends HMAC
|
||||
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
|
||||
obp.send_system(pkt)
|
||||
self.assertEqual(len(obp.transport.sent), 1)
|
||||
data, addr = obp.transport.sent[0]
|
||||
self.assertEqual(addr, (CFG['SYSTEMS']['OBP-1']['TARGET_IP'], CFG['SYSTEMS']['OBP-1']['TARGET_PORT']))
|
||||
# NETWORK_ID was written into the peer-id slot [11:15]
|
||||
self.assertEqual(data[11:15], CFG['SYSTEMS']['OBP-1']['NETWORK_ID'])
|
||||
|
||||
def test_outbound_send_server_uses_sendto(self):
|
||||
peer = hblink.HBSYSTEM('REPEATER-1', CFG, None)
|
||||
peer.transport = MockDatagramTransport()
|
||||
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
|
||||
# A logged-in outbound system sends call traffic to its server via sendto.
|
||||
peer._stats['CONNECTION'] = 'YES'
|
||||
peer.send_server(pkt)
|
||||
self.assertEqual(len(peer.transport.sent), 1)
|
||||
_, addr = peer.transport.sent[0]
|
||||
self.assertEqual(addr, CFG['SYSTEMS']['REPEATER-1']['SERVER_SOCKADDR'])
|
||||
|
||||
def test_outbound_send_server_drops_dmrd_when_not_connected(self):
|
||||
peer = hblink.HBSYSTEM('REPEATER-1', CFG, None)
|
||||
peer.transport = MockDatagramTransport()
|
||||
peer._stats['CONNECTION'] = 'NO'
|
||||
pkt = DMRD + b'\x00' + b'\x11\x22\x33' + b'\x00\x00\x09' + b'\xaa\xbb\xcc\xdd' + b'\x00' + b'\x00\x00\x00\x01' + b'\x00' * 33
|
||||
peer.send_server(pkt)
|
||||
self.assertEqual(peer.transport.sent, []) # call traffic dropped
|
||||
# but login/keepalive packets still go out
|
||||
peer.send_server(RPTL + b'\x00\x00\x00\x01')
|
||||
self.assertEqual(len(peer.transport.sent), 1)
|
||||
|
||||
|
||||
class TestSupersessionEnd(unittest.TestCase):
|
||||
"""routerHBP._end_slot_stream emits a matching END for a slot's un-terminated
|
||||
stream (superseded by a new stream, or timed out) so consumers don't leak it."""
|
||||
|
||||
def _router(self):
|
||||
import bridge
|
||||
cfg = {'REPORTS': {'REPORT': True}, 'SYSTEMS': CFG['SYSTEMS']}
|
||||
bridge.CONFIG = cfg
|
||||
captured = []
|
||||
|
||||
class Rep:
|
||||
def send_bridge_event(self, data):
|
||||
captured.append(data.decode() if isinstance(data, (bytes, bytearray)) else data)
|
||||
|
||||
return bridge.routerHBP('SERVER-1', cfg, Rep()), captured
|
||||
|
||||
def _activate(self, st, ct='GROUP VOICE'):
|
||||
st['RX_TYPE'] = HBPF_SLT_VHEAD # an active (non-terminated) stream
|
||||
st['RX_TERMINATED'] = False
|
||||
st['RX_CT'] = ct
|
||||
st['RX_STREAM_ID'] = bytes_4(12345)
|
||||
st['RX_PEER'] = bytes_4(312100)
|
||||
st['RX_RFS'] = bytes_3(3120001)
|
||||
st['RX_TGID'] = bytes_3(3100)
|
||||
st['RX_START'] = 100.0
|
||||
st['RX_TIME'] = 104.2
|
||||
|
||||
def test_emits_end_and_marks_terminated(self):
|
||||
r, cap = self._router()
|
||||
self._activate(r.STATUS[1])
|
||||
r._end_slot_stream(1)
|
||||
self.assertEqual(cap, ['GROUP VOICE,END,RX,SERVER-1,12345,312100,3120001,1,3100,4.20'])
|
||||
self.assertTrue(r.STATUS[1]['RX_TERMINATED'])
|
||||
# idempotent: a second call (already terminated) emits nothing more
|
||||
r._end_slot_stream(1)
|
||||
self.assertEqual(len(cap), 1)
|
||||
|
||||
def test_unit_call_type_preserved(self):
|
||||
r, cap = self._router()
|
||||
self._activate(r.STATUS[2], ct='UNIT VOICE')
|
||||
r._end_slot_stream(2)
|
||||
self.assertTrue(cap[0].startswith('UNIT VOICE,END,RX,SERVER-1,12345,'))
|
||||
|
||||
def test_no_event_for_idle_slot(self):
|
||||
r, cap = self._router()
|
||||
r._end_slot_stream(1) # slot starts terminated (RX_TERMINATED == True)
|
||||
self.assertEqual(cap, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Regression tests for the bridge routing indexes (index_bridges) added in the
|
||||
# 2026 cleanup. They prove the O(1) indexed lookup selects exactly the same
|
||||
# bridges/sources/targets as the original O(bridges x members) nested scan, for
|
||||
# randomized rule sets -- the transformation that replaced the per-frame scan in
|
||||
# routerOBP/routerHBP.group_received and the in-band signalling pass.
|
||||
#
|
||||
# Run from the repo root: venv/bin/python -m unittest discover -s tests
|
||||
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bridge
|
||||
from dmr_utils3.utils import bytes_3
|
||||
|
||||
SYSTEMS = ['ALPHA', 'BRAVO', 'CHARLIE', 'DELTA']
|
||||
TGIDS = [bytes_3(t) for t in (1, 9, 3100, 3129, 31665)]
|
||||
SLOTS = [1, 2]
|
||||
|
||||
|
||||
def _random_bridges(rng):
|
||||
bridges = {}
|
||||
for b in range(rng.randint(1, 6)):
|
||||
members = []
|
||||
for _ in range(rng.randint(0, 5)):
|
||||
members.append({
|
||||
'SYSTEM': rng.choice(SYSTEMS),
|
||||
'TS': rng.choice(SLOTS),
|
||||
'TGID': rng.choice(TGIDS),
|
||||
'ACTIVE': rng.random() < 0.7,
|
||||
})
|
||||
bridges['BRIDGE{}'.format(b)] = members
|
||||
return bridges
|
||||
|
||||
|
||||
# Reference: the ORIGINAL nested-scan selection logic from group_received.
|
||||
def _ref_targets(bridges, system, slot, dst):
|
||||
out = []
|
||||
for _bridge in bridges:
|
||||
for _src in bridges[_bridge]:
|
||||
if (_src['SYSTEM'] == system and _src['TGID'] == dst
|
||||
and _src['TS'] == slot and _src['ACTIVE'] == True):
|
||||
for _tgt in bridges[_bridge]:
|
||||
if _tgt['SYSTEM'] != system and _tgt['ACTIVE']:
|
||||
out.append((_bridge, id(_src), id(_tgt)))
|
||||
return out
|
||||
|
||||
|
||||
def _idx_targets(src_index, system, slot, dst):
|
||||
out = []
|
||||
for _bridge, _src, _members in src_index.get((system, slot, dst), ()):
|
||||
if _src['ACTIVE'] == True:
|
||||
for _tgt in _members:
|
||||
if _tgt['SYSTEM'] != system and _tgt['ACTIVE']:
|
||||
out.append((_bridge, id(_src), id(_tgt)))
|
||||
return out
|
||||
|
||||
|
||||
# Reference: the ORIGINAL nested-scan from the in-band signalling pass.
|
||||
def _ref_by_system(bridges, system):
|
||||
out = []
|
||||
for _bridge in bridges:
|
||||
for _sys in bridges[_bridge]:
|
||||
if _sys['SYSTEM'] == system:
|
||||
out.append((_bridge, id(_sys)))
|
||||
return out
|
||||
|
||||
|
||||
def _idx_by_system(by_system, system):
|
||||
return [(_bridge, id(_sys)) for _bridge, _sys in by_system.get(system, ())]
|
||||
|
||||
|
||||
class TestRoutingIndexEquivalence(unittest.TestCase):
|
||||
def test_source_selection_matches_bruteforce(self):
|
||||
rng = random.Random(2026)
|
||||
for _ in range(300):
|
||||
bridges = _random_bridges(rng)
|
||||
src_index, _ = bridge.index_bridges(bridges)
|
||||
for system in SYSTEMS:
|
||||
for slot in SLOTS:
|
||||
for dst in TGIDS:
|
||||
self.assertEqual(
|
||||
_idx_targets(src_index, system, slot, dst),
|
||||
_ref_targets(bridges, system, slot, dst),
|
||||
'mismatch for {} ts{} {!r}'.format(system, slot, dst))
|
||||
|
||||
def test_by_system_selection_matches_bruteforce(self):
|
||||
rng = random.Random(7)
|
||||
for _ in range(300):
|
||||
bridges = _random_bridges(rng)
|
||||
_, by_system = bridge.index_bridges(bridges)
|
||||
for system in SYSTEMS:
|
||||
self.assertEqual(_idx_by_system(by_system, system),
|
||||
_ref_by_system(bridges, system))
|
||||
|
||||
def test_missing_key_yields_no_targets(self):
|
||||
bridges = {'B': [{'SYSTEM': 'ALPHA', 'TS': 1, 'TGID': bytes_3(9), 'ACTIVE': True}]}
|
||||
src_index, by_system = bridge.index_bridges(bridges)
|
||||
self.assertEqual(src_index.get(('NOPE', 1, bytes_3(9)), []), [])
|
||||
self.assertEqual(by_system.get('NOPE', []), [])
|
||||
|
||||
def test_index_uses_live_active_flag(self):
|
||||
# The index references the live member dicts, so flipping ACTIVE after
|
||||
# building must take effect without rebuilding.
|
||||
member = {'SYSTEM': 'ALPHA', 'TS': 1, 'TGID': bytes_3(9), 'ACTIVE': True}
|
||||
target = {'SYSTEM': 'BRAVO', 'TS': 1, 'TGID': bytes_3(9), 'ACTIVE': True}
|
||||
bridges = {'B': [member, target]}
|
||||
src_index, _ = bridge.index_bridges(bridges)
|
||||
self.assertEqual(len(_idx_targets(src_index, 'ALPHA', 1, bytes_3(9))), 1)
|
||||
target['ACTIVE'] = False
|
||||
self.assertEqual(len(_idx_targets(src_index, 'ALPHA', 1, bytes_3(9))), 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# migrate_obp_rules.py -- one-shot migration for the OpenBridge config-model change.
|
||||
#
|
||||
# HBlink3 no longer configures OpenBridge (MODE: OPENBRIDGE) systems as inline
|
||||
# BRIDGES members. They now live in a per-OBP table:
|
||||
#
|
||||
# OBP_BRIDGES = { <obp system> : { <bridge name> : <TGID> } } # TS 1, or (TGID, TS)
|
||||
#
|
||||
# This tool reads your existing hblink.cfg (to learn which systems are OPENBRIDGE)
|
||||
# and your existing rules.py (old inline form), then:
|
||||
# * lifts every inline OBP member out of BRIDGES into OBP_BRIDGES,
|
||||
# * drops any bridge left with no members (OBP_BRIDGES recreates it at load),
|
||||
# * flags the two problems the new loader would reject/warn on
|
||||
# - a TGID mapped to >1 bridge on one OBP (ingress fork) -> ERROR
|
||||
# - a bridge with different TGIDs across OBPs (renumber) -> WARNING
|
||||
# * prints a summary of what moved, and writes a new rules file.
|
||||
#
|
||||
# It never overwrites your rules.py: output goes to a separate file you review
|
||||
# and rename. Comments and commented-out members in the old file are NOT carried
|
||||
# over (the tool works on the active, executed config), so re-add any notes by hand.
|
||||
#
|
||||
# venv/bin/python tools/migrate_obp_rules.py [--config hblink.cfg] [--rules rules.py] [--out rules.py.migrated]
|
||||
#
|
||||
# Exit status is non-zero if an ingress-fork ERROR was found (the output still
|
||||
# gets written so you can see and fix it).
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _openbridge_systems(cfg_path):
|
||||
"""Section names in hblink.cfg whose MODE is OPENBRIDGE."""
|
||||
cp = configparser.ConfigParser()
|
||||
if not cp.read(cfg_path):
|
||||
sys.exit('ERROR: could not read config file: {}'.format(cfg_path))
|
||||
obp = set()
|
||||
for section in cp.sections():
|
||||
if cp.has_option(section, 'MODE') and cp.get(section, 'MODE').strip().upper() == 'OPENBRIDGE':
|
||||
obp.add(section)
|
||||
return obp
|
||||
|
||||
|
||||
def _load_rules(rules_path):
|
||||
spec = importlib.util.spec_from_file_location('rules_old', rules_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
sys.exit('ERROR: could not import rules file {}: {}'.format(rules_path, e))
|
||||
return mod
|
||||
|
||||
|
||||
def _fmt_member(m):
|
||||
return ("{{'SYSTEM': {!r}, 'TS': {}, 'TGID': {}, 'ACTIVE': {}, "
|
||||
"'TIMEOUT': {}, 'TO_TYPE': {!r}, 'ON': {}, 'OFF': {}, 'RESET': {}}},").format(
|
||||
m['SYSTEM'], m['TS'], m['TGID'], m['ACTIVE'],
|
||||
m.get('TIMEOUT', 2), m.get('TO_TYPE', 'NONE'),
|
||||
m.get('ON', []), m.get('OFF', []), m.get('RESET', []))
|
||||
|
||||
|
||||
def migrate(bridges, obp_systems):
|
||||
"""Return (new_bridges, obp_bridges, moved, errors, warnings)."""
|
||||
new_bridges = {}
|
||||
obp_bridges = {} # obp -> {bridge: tgid_or_(tgid, ts)}
|
||||
moved = [] # (obp, bridge, tgid, ts)
|
||||
errors = []
|
||||
warnings = []
|
||||
tgid_to_bridge = {} # (obp, tgid) -> bridge, ingress-fork check
|
||||
bridge_obp_tgid = {} # bridge -> {obp: tgid}, renumber check
|
||||
|
||||
for bridge_name, members in bridges.items():
|
||||
kept = []
|
||||
for m in members:
|
||||
if m['SYSTEM'] in obp_systems:
|
||||
tgid, ts = m['TGID'], m.get('TS', 1)
|
||||
key = (m['SYSTEM'], tgid)
|
||||
if key in tgid_to_bridge and tgid_to_bridge[key] != bridge_name:
|
||||
errors.append('OBP "{}" TGID {} maps to more than one bridge ("{}" and "{}") -- '
|
||||
'ingress fork; keep only one before starting HBlink3'
|
||||
.format(m['SYSTEM'], tgid, tgid_to_bridge[key], bridge_name))
|
||||
tgid_to_bridge[key] = bridge_name
|
||||
obp_bridges.setdefault(m['SYSTEM'], {})[bridge_name] = tgid if ts == 1 else (tgid, ts)
|
||||
bridge_obp_tgid.setdefault(bridge_name, {})[m['SYSTEM']] = tgid
|
||||
moved.append((m['SYSTEM'], bridge_name, tgid, ts))
|
||||
else:
|
||||
kept.append(m)
|
||||
if kept:
|
||||
new_bridges[bridge_name] = kept # drop bridges left empty (OBP_BRIDGES recreates them)
|
||||
|
||||
for bridge_name, obp_tgids in bridge_obp_tgid.items():
|
||||
if len(set(obp_tgids.values())) > 1:
|
||||
detail = ', '.join('{}={}'.format(o, t) for o, t in sorted(obp_tgids.items()))
|
||||
warnings.append('bridge "{}" renumbers TGID across OpenBridges ({}) -- legal but unusual'
|
||||
.format(bridge_name, detail))
|
||||
|
||||
return new_bridges, obp_bridges, moved, errors, warnings
|
||||
|
||||
|
||||
def render(new_bridges, obp_bridges, unit):
|
||||
L = []
|
||||
L.append('# Generated by tools/migrate_obp_rules.py -- REVIEW before use.')
|
||||
L.append('# OpenBridge members were moved from BRIDGES into OBP_BRIDGES; comments from')
|
||||
L.append('# the original rules.py were not preserved.')
|
||||
L.append('')
|
||||
L.append('BRIDGES = {')
|
||||
for bridge_name, members in new_bridges.items():
|
||||
L.append(' {!r}: ['.format(bridge_name))
|
||||
for m in members:
|
||||
L.append(' ' + _fmt_member(m))
|
||||
L.append(' ],')
|
||||
L.append('}')
|
||||
L.append('')
|
||||
L.append('OBP_BRIDGES = {')
|
||||
for obp, table in obp_bridges.items():
|
||||
L.append(' {!r}: {{'.format(obp))
|
||||
for bridge_name, val in table.items():
|
||||
L.append(' {!r}: {},'.format(bridge_name, val))
|
||||
L.append(' },')
|
||||
L.append('}')
|
||||
L.append('')
|
||||
L.append('UNIT = {!r}'.format(list(unit)))
|
||||
L.append('')
|
||||
return '\n'.join(L)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Migrate rules.py OpenBridge members to the OBP_BRIDGES table')
|
||||
ap.add_argument('--config', default='hblink.cfg', help='hblink.cfg to read system MODEs from')
|
||||
ap.add_argument('--rules', default='rules.py', help='existing (old-form) rules.py')
|
||||
ap.add_argument('--out', default=None, help='output file (default: <rules>.migrated)')
|
||||
args = ap.parse_args()
|
||||
|
||||
out_path = args.out or (args.rules + '.migrated')
|
||||
obp_systems = _openbridge_systems(args.config)
|
||||
rules = _load_rules(args.rules)
|
||||
bridges = getattr(rules, 'BRIDGES', {})
|
||||
unit = getattr(rules, 'UNIT', [])
|
||||
|
||||
if getattr(rules, 'OBP_BRIDGES', None):
|
||||
print('NOTE: {} already defines OBP_BRIDGES; this looks migrated already.'.format(args.rules))
|
||||
|
||||
new_bridges, obp_bridges, moved, errors, warnings = migrate(bridges, obp_systems)
|
||||
|
||||
print('OpenBridge systems found in {}: {}'.format(args.config, ', '.join(sorted(obp_systems)) or '(none)'))
|
||||
print('\n{} inline OBP member(s) moved into OBP_BRIDGES:'.format(len(moved)))
|
||||
for obp, bridge_name, tgid, ts in moved:
|
||||
print(' {:<12} {:<14} TGID {}{}'.format(obp, bridge_name, tgid, '' if ts == 1 else ' (TS {})'.format(ts)))
|
||||
dropped = [b for b in bridges if b not in new_bridges]
|
||||
if dropped:
|
||||
print('\nBridges now defined only via OBP_BRIDGES (dropped from BRIDGES): {}'.format(', '.join(dropped)))
|
||||
for w in warnings:
|
||||
print('\nWARNING: ' + w)
|
||||
for e in errors:
|
||||
print('\nERROR: ' + e)
|
||||
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
f.write(render(new_bridges, obp_bridges, unit))
|
||||
print('\nWrote {}. Review it, then replace your rules.py with it.'.format(out_path))
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user