commit 37b399c1c2c85f9d25f72c7af566b6ca98d3b8ab Author: Marcus Kida Date: Mon Jun 8 23:14:05 2026 +0200 Initial Commit 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