Initial Commit
This commit is contained in:
commit
37b399c1c2
9 changed files with 336 additions and 0 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
9
.env.example
Normal file
9
.env.example
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal 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
21
LICENSE
Normal 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
96
README.md
Normal 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
5
compose.yml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
services:
|
||||||
|
bm-lh:
|
||||||
|
build: .
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
184
main.py
Normal file
184
main.py
Normal 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Ü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
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
python-socketio[client]
|
||||||
|
requests
|
||||||
|
websocket-client
|
||||||
Loading…
Reference in a new issue