From 7f08a655d8b80b1924c0a4e1429289634e0f3346 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Mon, 8 Jun 2026 23:14:05 +0200 Subject: [PATCH] Initial Commit --- .dockerignore | 5 ++ .env.example | 9 +++ .gitignore | 3 + Dockerfile | 10 +++ LICENSE | 21 ++++++ README.md | 96 ++++++++++++++++++++++++++ compose.yml | 5 ++ main.py | 172 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 9 files changed, 324 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 compose.yml create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..59bc4ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.env +.git +.gitignore +__pycache__/ +*.pyc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1176dc7 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +TG_TO_WATCH=26235 +CLUSTER_TO_WATCH=Cluster Name +TELEGRAM_CHANNEL=@your_channel +TELEGRAM_API_KEY=your_telegram_bot_api_key +CLUSTER_WATCH=true +# Optional: repeaterid:slot pairs that should never count as a slot match +SLOT_EXCLUDE=262399:2,262383:2 +# Optional: repeater IDs whose TG8 traffic should be ignored +TG8_EXCLUDE_REPEATERS=262399 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..900c68d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +CMD ["python", "-u", "main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a1ff72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Marcus Kida + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..20f600d --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# BrandMeister LastHeard Telegram Bot + +A small bot that listens to the [BrandMeister](https://brandmeister.network) DMR +"LastHeard" stream and posts a formatted message to a Telegram channel whenever a +relevant transmission ends. It is useful for keeping a club or regional channel +informed about activity on a specific Talkgroup or repeater cluster. + +## What it does + +The bot connects to the BrandMeister LastHeard Socket.IO stream and reacts to +`Session-Stop` events. It sends a Telegram message when **either** of the +following matches: + +- **Mirror Talkgroup** — the transmission's destination is the Talkgroup you set + via `TG_TO_WATCH`. +- **TG8 cluster** — the transmission is on TG8 *and* came in via a repeater that + belongs to the cluster named in `CLUSTER_TO_WATCH` (only when `CLUSTER_WATCH` + is enabled). Cluster membership is resolved at startup from the BrandMeister + API. + +Only recent events (stopped within the last ~20 seconds) trigger a message, so +old/replayed events are ignored. + +Each Telegram message includes the source callsign, ID, name, talker alias, the +repeater/hotspot used to access the network, slot, Talkgroup, master, RSSI and +BER. + +## Requirements + +- A Telegram bot token (create one via [@BotFather](https://t.me/BotFather)) and + a channel the bot can post to. +- Either Docker (recommended) or Python 3.12+. + +## Configuration + +The bot is configured entirely through environment variables. Copy the example +file and fill in your values: + +```bash +cp .env.example .env +``` + +| Variable | Required | Description | +| ------------------ | -------- | --------------------------------------------------------------------------- | +| `TG_TO_WATCH` | yes | Talkgroup ID to mirror to Telegram (e.g. `26235`). | +| `CLUSTER_TO_WATCH` | yes | BrandMeister cluster name used for the TG8 check (e.g. `Cluster Name`). | +| `TELEGRAM_CHANNEL` | yes | Target channel/chat ID (e.g. `@your_channel`). | +| `TELEGRAM_API_KEY` | yes | Telegram bot token from BotFather. | +| `CLUSTER_WATCH` | no | `true`/`false` — enable the TG8 cluster check. Defaults to `true`. | +| `SLOT_EXCLUDE` | no | Comma-separated `repeaterid:slot` pairs that never count as a slot match (e.g. `262399:2,262383:2`). | +| `TG8_EXCLUDE_REPEATERS`| no | Comma-separated repeater IDs whose TG8 traffic is ignored (e.g. `262399`). | + +## Running + +### With Docker Compose (recommended) + +```bash +docker compose up -d --build +``` + +The service restarts automatically unless explicitly stopped. View logs with: + +```bash +docker compose logs -f +``` + +### With plain Docker + +```bash +docker build -t bm-lh . +docker run --env-file .env --restart unless-stopped bm-lh +``` + +### Locally with Python + +```bash +pip install -r requirements.txt +set -a && source .env && set +a # load env vars into the shell +python -u main.py +``` + +## How it works + +1. On startup the bot queries the BrandMeister API (`/v2/cluster`) to find the + cluster matching `CLUSTER_TO_WATCH`, then fetches its member repeaters + (`/v2/cluster//members`). +2. It opens a WebSocket connection to the LastHeard stream + (`https://api.brandmeister.network`, path `/lh/socket.io`) and subscribes by + emitting `join` with `everything`. +3. For every incoming `mqtt` event it parses the payload and decides whether to + forward a Telegram message based on the rules described above. +4. The connection auto-reconnects on errors. + +## License + +Released under the [MIT License](LICENSE). diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..1e471b4 --- /dev/null +++ b/compose.yml @@ -0,0 +1,5 @@ +services: + bm-lh: + build: . + env_file: .env + restart: unless-stopped diff --git a/main.py b/main.py new file mode 100644 index 0000000..dc59858 --- /dev/null +++ b/main.py @@ -0,0 +1,172 @@ +import socketio +import json +import time +import os +import requests + +# Read variables from environment +tg_to_watch = int(os.environ["TG_TO_WATCH"]) +cluster_to_watch = os.environ["CLUSTER_TO_WATCH"] +telegram_channel = os.environ["TELEGRAM_CHANNEL"] +telegram_api_key = os.environ["TELEGRAM_API_KEY"] +cluster_watch = os.environ.get("CLUSTER_WATCH", "true").lower() == "true" + + +def _as_int(value): + # The LastHeard stream is inconsistent about whether IDs/slots arrive as + # ints or strings, so normalise everything we compare to int. + try: + return int(value) + except (TypeError, ValueError): + return value + + +def _parse_pairs(raw): + # "262399:2,262383:2" -> {(262399, 2), (262383, 2)} + pairs = set() + for item in raw.split(","): + item = item.strip() + if not item: + continue + rid, _, slot = item.partition(":") + pairs.add((_as_int(rid), _as_int(slot))) + return pairs + + +def _parse_ids(raw): + # "262399,262383" -> {262399, 262383} + return {_as_int(item.strip()) for item in raw.split(",") if item.strip()} + + +# Repeater/slot combinations that should never count as a slot match, and +# repeater IDs whose TG8 traffic should be ignored. Both are optional and +# replace what used to be hardcoded repeater IDs in the event handler. +slot_exclusions = _parse_pairs(os.environ.get("SLOT_EXCLUDE", "")) +tg8_exclude_repeaters = _parse_ids(os.environ.get("TG8_EXCLUDE_REPEATERS", "")) + +print(f"TG to watch: {tg_to_watch}") +print(f"Cluster to watch: {cluster_to_watch}") +print(f"Telegram channel: {telegram_channel}") +print(f"Cluster watch: {cluster_watch}") +print(f"Slot exclusions: {slot_exclusions or 'none'}") +print(f"TG8 exclude repeaters: {tg8_exclude_repeaters or 'none'}") + + +######################################################################## + + +def send_telegram_message(text): + url = f"https://api.telegram.org/bot{telegram_api_key}/sendMessage" + r = requests.post(url, data={"chat_id": telegram_channel, "text": text, "parse_mode": "HTML"}) + print(f"Telegram API response: {r.status_code} {r.text}") + +# Read the BM cluster database. +# Note: the former endpoint /v2/cluster/byName?name=... no longer exists. +# Instead /v2/cluster returns the full list of all clusters, from which we +# pick the matching one by clusterName. +bm_clusterinfo_uri = "https://api.brandmeister.network/v2/cluster" + +# Parse the response so it is easier to work with +response = requests.get(bm_clusterinfo_uri) + +bm_clusters_json = response.json() + +# Filter out the cluster(s) with a matching name +bm_clustermasters_json = [c for c in bm_clusters_json if c["clusterName"] == cluster_to_watch] + +if not bm_clustermasters_json: + print(f"Cluster '{cluster_to_watch}' not found in the Brandmeister API") + +# List of repeaters that belong to the cluster +list_of_repeater = [] + + +# First loop: read the members of the cluster +for master in bm_clustermasters_json: + bm_master_repeaters_with_cluster_uri = "https://api.brandmeister.network/v2/cluster/" + str(master["id"]) +"/members" + response_repeater_in_cluster = requests.get(bm_master_repeaters_with_cluster_uri) + try: + repeater_list_with_cluster = (response_repeater_in_cluster.json())[0]["members"] + for repeater in repeater_list_with_cluster: + # Build the list of repeaters that belong to the cluster + list_of_repeater.append (repeater) + except: + print("Error") + +# SocketIO setup for the MQTT socket handling +sio = socketio.Client(reconnection=True, reconnection_attempts=0, reconnection_delay=5, reconnection_delay_max=60) + +@sio.event +def connect(): + print('connected to server') + # Since the API change the stream has to be actively subscribed to, + # otherwise the server keeps the connection open but sends no data. + sio.emit("join", "everything") + print('subscribed to LastHeard stream (join/everything)') + +@sio.event +def disconnect(): + print('disconnected from server, reconnecting...') + +@sio.on("mqtt") +def on_mqtt(data): + epoch_time = int(time.time()) + # Unpack the payload into variables, which is a bit easier to work with + datapayload = (data["payload"]) + jsondata = json.loads(datapayload) + payload_dst_id = jsondata["DestinationID"] + event_type = jsondata["Event"] + session_stop_time = jsondata["Stop"] + max_old_time = epoch_time - 20 + + # Build the message that will be sent to Telegram + telegram_message = "Callsign: " + jsondata["SourceCall"] + " ID: " + str(jsondata["SourceID"]) + " \nName: " +jsondata["SourceName"] + " \nTalkeralias: " + jsondata["TalkerAlias"] + " \n-----------------------------------\nAccess via: " + jsondata["LinkName"] + " \nVia repeater: " + jsondata["LinkCall"] + "\nSlot: " + str(jsondata["Slot"]) + " TG: " + str(jsondata["DestinationID"]) + "\nMaster: " + str(jsondata["Master"]) + "\nRSSI: " + str(jsondata["RSSI"]) + " dBm BER: " + str(round(jsondata["BER"],2)) + "%" + context_id = _as_int(jsondata["ContextID"]) + event_slot = _as_int(jsondata["Slot"]) + + # Find the cluster member matching this event's repeater, if any. + matched_repeater = next( + (rep for rep in list_of_repeater if _as_int(rep["repeaterid"]) == context_id), + None, + ) + repeater_in_cluster = matched_repeater is not None + # A slot match requires the repeater to be in the cluster, its configured + # slot to match the event slot, and the combination not to be excluded. + repeater_match_slot = ( + repeater_in_cluster + and _as_int(matched_repeater["slot"]) == event_slot + and (context_id, event_slot) not in slot_exclusions + ) + + # TG8 cluster check + tg8_match = payload_dst_id == 8 + is_session_stop = event_type == "Session-Stop" + is_recent = session_stop_time > max_old_time + + # Skip TG8 messages from repeaters in the exclude list + skip_tg8_for_repeater = tg8_match and context_id in tg8_exclude_repeaters + + if tg8_match and is_session_stop and is_recent and repeater_match_slot and repeater_in_cluster and cluster_watch and not skip_tg8_for_repeater: + print (jsondata) + print ("matched via TG8") + send_telegram_message(telegram_message) + + # Mirror TG check + tg_match = payload_dst_id == tg_to_watch + + if tg_match and is_session_stop and is_recent: + print (jsondata) + print ("matched via Mirror-TG") + send_telegram_message(telegram_message) + return + +bm_url = 'https://api.brandmeister.network' +bm_path = "/lh/socket.io" + +while True: + try: + sio.connect(url=bm_url, socketio_path=bm_path, transports="websocket") + sio.wait() + except Exception as e: + print(f"Connection error: {e}, retrying in 10s...") + time.sleep(10) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d3686bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-socketio[client] +requests +websocket-client