From 37b399c1c2c85f9d25f72c7af566b6ca98d3b8ab 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 | 184 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 9 files changed, 336 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..8d49143 --- /dev/null +++ b/main.py @@ -0,0 +1,184 @@ +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}") + +# Auslesen der BM Cluster-Datenbank +# Hinweis: Der frühere Endpoint /v2/cluster/byName?name=... existiert nicht mehr. +# Stattdessen liefert /v2/cluster die komplette Liste aller Cluster, aus der wir +# den passenden anhand des clusterName heraussuchen. +bm_clusterinfo_uri = "https://api.brandmeister.network/v2/cluster" + +# Umwandeln damit es auslesbarer wird +response = requests.get(bm_clusterinfo_uri) + +bm_clusters_json = response.json() + +# Den/die Cluster mit passendem Namen herausfiltern +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}' nicht gefunden in der Brandmeister API") + +# Wegen dem Durchlauf, einmal das Array leer machen und wenn es nicht da ist erstellen. +list_of_repeater = [] +repeater_list_with_cluster = [] + + +# erste Forschleife, auslesen der Members des Clusters +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: + # Erstellen der Liste mit den Repeatern die den Cluster haben + list_of_repeater.append (repeater) + except: + print("Error") + +# print(f"Repeaters in cluster: {len(list_of_repeater)}") +# for rep in list_of_repeater: +# print(f" {rep['repeaterid']} slot {rep['slot']}") + +# SocketIO Krams für das 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') + # Seit der API-Umstellung muss der Stream aktiv abonniert werden, sonst + # haelt der Server die Verbindung zwar offen, sendet aber keine Daten. + 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()) + # Aufbereitung des Arrays in Variablen, ist bisschen einfacher für den Umgang + 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 + # print(f"MQTT event: {event_type} - {jsondata['SourceCall']} -> TG {payload_dst_id}") + + # Vorbereiten der Message die in Telegram geschickt wird + telegram_message = "Callsign: " + jsondata["SourceCall"] + " ID: " + str(jsondata["SourceID"]) + " \nName: " +jsondata["SourceName"] + " \nTalkeralias: " + jsondata["TalkerAlias"] + " \n-----------------------------------\nEinstieg via: " + jsondata["LinkName"] + " \nÜber 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: + # print(f"[TG8] event=Session-Stop:{is_session_stop} recent:{is_recent} slot_match:{repeater_match_slot} in_cluster:{repeater_in_cluster} cluster_watch:{cluster_watch}") + + 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 ("via TG8 gerufen") + send_telegram_message(telegram_message) + + # Mirror TG check + tg_match = payload_dst_id == tg_to_watch + + # if tg_match: + # print(f"[Mirror-TG] event=Session-Stop:{is_session_stop} recent:{is_recent}") + + if tg_match and is_session_stop and is_recent: + print (jsondata) + print ("via Mirror-TG gerufen") + 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