Initial Commit

This commit is contained in:
Marcus Kida 2026-06-08 23:14:05 +02:00
commit 37b399c1c2
9 changed files with 336 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.env
.git
.gitignore
__pycache__/
*.pyc

9
.env.example Normal file
View file

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

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc

10
Dockerfile Normal file
View file

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

21
LICENSE Normal file
View file

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

96
README.md Normal file
View file

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

5
compose.yml Normal file
View file

@ -0,0 +1,5 @@
services:
bm-lh:
build: .
env_file: .env
restart: unless-stopped

184
main.py Normal file
View file

@ -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: <b><a href='https://brandmeister.network/index.php?page=profile&call=" +jsondata["SourceCall"] + "'>" + jsondata["SourceCall"] + "</a></b> ID: <b>" + str(jsondata["SourceID"]) + " </b> \nName: " +jsondata["SourceName"] + " \nTalkeralias: <b>" + jsondata["TalkerAlias"] + "</b> \n-----------------------------------\nEinstieg via: <b>" + jsondata["LinkName"] + " </b>\n&#220;ber Repeater: <b><a href='https://brandmeister.network/?page=device&id=" + str(jsondata["ContextID"]) + "'>" + jsondata["LinkCall"] + "</a></b>\nSlot: " + str(jsondata["Slot"]) + " TG: " + str(jsondata["DestinationID"]) + "\nMaster: " + str(jsondata["Master"]) + "\nRSSI: <b>" + str(jsondata["RSSI"]) + " dBm</b> BER: <b>" + str(round(jsondata["BER"],2)) + "%</b>"
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)

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
python-socketio[client]
requests
websocket-client