YSF2DMR: DMR Private Calls über C4FM automatisch beantworten
Copy-&-Paste-Patch für YSF2DMR auf Pi-Star und WPSD: DMR Private Calls über C4FM beantworten und nach 30 Sekunden automatisch zur vorherigen TG zurückkehren.

Wer YSF2DMR auf einem Pi-Star-, WPSD– oder vergleichbaren MMDVM-Hotspot nutzt, kann C4FM/YSF sehr bequem mit DMR-Talkgroups verbinden. Eine kleine Lücke fällt aber spätestens beim ersten eingehenden DMR Private Call auf: Der Einzelruf kommt auf der C4FM-Seite an, eine direkte Antwort würde ohne zusätzliche Logik jedoch weiterhin an das bisher eingestellte DMR-Ziel beziehungsweise die aktive Talkgroup gehen.
Genau dafür ist dieser Patch gedacht. Er merkt sich beim eingehenden DMR Private Call das bisherige Ziel, schaltet YSF2DMR temporär auf die DMR-ID des Anrufers um und setzt den Call-Type auf FLCO_USER_USER. Dadurch kann die Gegenstation direkt vom C4FM-Funkgerät beantwortet werden. Nach 30 Sekunden Inaktivität stellt YSF2DMR automatisch die zuvor aktive Talkgroup wieder her.
Was der YSF2DMR-Patch ändert
- Ein eingehender DMR Private Call an die eigene DMR-ID beziehungsweise Hotspot-ID wird erkannt.
- Das bisherige DMR-Ziel und der bisherige Call-Type werden gespeichert.
- Die DMR-ID des Anrufers wird temporär als Antwortziel gesetzt.
- Lokale C4FM-/YSF-Aussendungen werden während dieses Fensters als DMR Private Call gesendet.
- Aktivität des eingehenden Private Calls und die lokale C4FM-Aussendung halten das Antwortfenster aktiv.
- Nach 30 Sekunden Inaktivität wird automatisch das vorherige Ziel wiederhergestellt.
- Ein bewusster Wires-X-/DTMF-Zielwechsel beendet den temporären Private-Reply-Modus sofort.
Pi-Star, WPSD und MMDVM: warum das How-to für alle passt
YSF2DMR ist ein Crossmode-Client aus dem MMDVM-Umfeld. Sowohl Pi-Star als auch WPSD starten YSF2DMR als eigenen Dienst und verwenden typischerweise die produktive Konfiguration unter /etc/ysf2dmr. Das eigentliche Binary liegt üblicherweise unter /usr/local/bin/YSF2DMR. Das Installationsskript unten verlässt sich trotzdem nicht blind auf einen festen Binary-Pfad, sondern ermittelt den laufenden Pfad automatisch.
Beim Quellcode wird zuerst nach bekannten Pi-Star-/WPSD-Source-Trees gesucht. Ist keiner vorhanden, holt das Skript einen frischen MMDVM_CM-Quellstand und baut YSF2DMR daraus. Auf einem klassischen Pi-Star wird bei vorhandenen rpi-rw/rpi-ro-Kommandos außerdem das Dateisystem für die Änderung automatisch beschreibbar gemacht und am Ende wieder zurückgeschaltet.
/usr/local/bin/YSF2DMR wieder durch die Paket-/Distributionsversion ersetzen. Das Skript legt deshalb vor der Installation automatisch Backups an.Copy & Paste: Patch einspielen, YSF2DMR kompilieren und installieren
Der folgende Block erledigt den kompletten Ablauf. Er sucht einen vorhandenen YSF2DMR-Source-Tree, sichert YSF2DMR.cpp, Conf.cpp und Conf.h, ergänzt die Private-Reply-Logik, kompiliert YSF2DMR neu, sichert das bisherige Binary und die laufende Konfiguration, trägt die beiden neuen Optionen ein und startet anschließend ysf2dmr.service neu.
Den gesamten Block in eine Shell kopieren. Auf Pi-Star/WPSD sollte er als root beziehungsweise über sudo ausgeführt werden.
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte als root ausführen: sudo bash /tmp/da6it-ysf2dmr-private-reply.sh"
exit 1
fi
STAMP="$(date +%Y%m%d-%H%M%S)"
CONFIG="/etc/ysf2dmr"
SERVICE="ysf2dmr.service"
REMOUNT_RO=0
cleanup() {
if [ "$REMOUNT_RO" -eq 1 ] && command -v rpi-ro >/dev/null 2>&1; then
rpi-ro >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
if command -v rpi-rw >/dev/null 2>&1; then
rpi-rw
REMOUNT_RO=1
fi
if ! command -v make >/dev/null 2>&1 || ! command -v g++ >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential python3 git
fi
SRC=""
for CANDIDATE in \
/home/pi-star/ysf2dmr-dev/WPSD_CustomBinaries-Source/MMDVM_CM/YSF2DMR \
/home/pi-star/mmdvm-cm-da6it/YSF2DMR \
/home/pi-star/MMDVM_CM/YSF2DMR \
/root/MMDVM_CM/YSF2DMR
do
if [ -f "$CANDIDATE/YSF2DMR.cpp" ] && [ -f "$CANDIDATE/Conf.cpp" ] && [ -f "$CANDIDATE/Conf.h" ]; then
SRC="$CANDIDATE"
break
fi
done
if [ -z "$SRC" ]; then
if ! command -v git >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git
fi
BASE="/home/pi-star/MMDVM_CM-da6it-private-reply"
rm -rf "$BASE"
git clone --depth=1 https://github.com/juribeparada/MMDVM_CM.git "$BASE"
SRC="$BASE/YSF2DMR"
fi
echo "Verwendeter YSF2DMR-Quellcode: $SRC"
for FILE in YSF2DMR.cpp Conf.cpp Conf.h; do
cp -a "$SRC/$FILE" "$SRC/$FILE.da6it-backup-$STAMP"
done
python3 - "$SRC" <<'PY'
from pathlib import Path
import re
import sys
src = Path(sys.argv[1])
def fail(message):
raise SystemExit("PATCH FEHLER: " + message)
def write(path, text):
path.write_text(text, encoding="utf-8")
# ------------------------------------------------------------------
# Conf.h
# ------------------------------------------------------------------
path = src / "Conf.h"
text = path.read_text(encoding="utf-8")
if "getDMRNetworkAutoPrivateReply" not in text:
pattern = r'(\s*bool\s+getDMRNetworkPCUnlink\(\) const;\n)'
repl = (
r'\1'
' bool getDMRNetworkAutoPrivateReply() const;\n'
' unsigned int getDMRNetworkPrivateReplyTimeout() const;\n'
)
text, n = re.subn(pattern, repl, text, count=1)
if n != 1:
fail("Conf.h: Getter-Position nicht gefunden")
pattern = r'(\s*bool\s+m_dmrNetworkPCUnlink;\n)'
repl = (
r'\1'
' bool m_dmrNetworkAutoPrivateReply;\n'
' unsigned int m_dmrNetworkPrivateReplyTimeout;\n'
)
text, n = re.subn(pattern, repl, text, count=1)
if n != 1:
fail("Conf.h: Member-Position nicht gefunden")
write(path, text)
# ------------------------------------------------------------------
# Conf.cpp
# ------------------------------------------------------------------
path = src / "Conf.cpp"
text = path.read_text(encoding="utf-8")
if "m_dmrNetworkAutoPrivateReply" not in text:
text, n = re.subn(
r'(m_dmrNetworkPCUnlink\(false\),\n)',
r'\1m_dmrNetworkAutoPrivateReply(false),\n'
r'm_dmrNetworkPrivateReplyTimeout(60U),\n',
text,
count=1,
)
if n != 1:
fail("Conf.cpp: Konstruktor-Position nicht gefunden")
pattern = (
r'(\t\telse if \(::strcmp\(key, "PCUnlink"\) == 0\)\n'
r'\t\t\tm_dmrNetworkPCUnlink = ::atoi\(value\) == 1;\n)'
)
repl = (
r'\1'
'\t\telse if (::strcmp(key, "AutoPrivateReply") == 0)\n'
'\t\t\tm_dmrNetworkAutoPrivateReply = ::atoi(value) == 1;\n'
'\t\telse if (::strcmp(key, "PrivateReplyTimeout") == 0)\n'
'\t\t\tm_dmrNetworkPrivateReplyTimeout = (unsigned int)::atoi(value);\n'
)
text, n = re.subn(pattern, repl, text, count=1)
if n != 1:
fail("Conf.cpp: DMR-Network-Parser nicht gefunden")
pattern = (
r'(bool CConf::getDMRNetworkPCUnlink\(\) const\n'
r'\{\n\treturn m_dmrNetworkPCUnlink;\n\}\n)'
)
repl = (
r'\1\n'
'bool CConf::getDMRNetworkAutoPrivateReply() const\n'
'{\n\treturn m_dmrNetworkAutoPrivateReply;\n}\n\n'
'unsigned int CConf::getDMRNetworkPrivateReplyTimeout() const\n'
'{\n\treturn m_dmrNetworkPrivateReplyTimeout;\n}\n'
)
text, n = re.subn(pattern, repl, text, count=1)
if n != 1:
fail("Conf.cpp: Getter-Block nicht gefunden")
write(path, text)
# ------------------------------------------------------------------
# YSF2DMR.cpp
# ------------------------------------------------------------------
path = src / "YSF2DMR.cpp"
text = path.read_text(encoding="utf-8")
if "privateReplyActive" not in text:
old = "\tCStopWatch dmrWatch;\n"
if old not in text:
fail("YSF2DMR.cpp: dmrWatch nicht gefunden")
text = text.replace(old, old + "\tCStopWatch privateReplyWatch;\n", 1)
old = "\tbool enableUnlink = m_conf.getDMRNetworkEnableUnlink();\n\tbool unlinkReceived = false;\n"
if old not in text:
fail("YSF2DMR.cpp: DMR-Konfigurationsblock nicht gefunden")
new = old + (
"\tbool autoPrivateReply = m_conf.getDMRNetworkAutoPrivateReply();\n"
"\tunsigned int privateReplyTimeout = m_conf.getDMRNetworkPrivateReplyTimeout();\n"
"\tif (privateReplyTimeout == 0U)\n"
"\t\tprivateReplyTimeout = 60U;\n\n"
"\tLogMessage(\"Private Call Reply: %s, timeout %u seconds\",\n"
"\t\tautoPrivateReply ? \"enabled\" : \"disabled\",\n"
"\t\tprivateReplyTimeout);\n"
)
text = text.replace(old, new, 1)
old = "\tunsigned int tglistOpt = 0;\n"
if old not in text:
fail("YSF2DMR.cpp: tglistOpt nicht gefunden")
new = old + (
"\n\tbool privateReplyActive = false;\n"
"\tunsigned int privateReplyTarget = 0U;\n"
"\tunsigned int privateReplySavedDstId = 0U;\n"
"\tFLCO privateReplySavedFLCO = FLCO_GROUP;\n\n"
"\tauto cancelPrivateReply = [&]() {\n"
"\t\tif (privateReplyActive) {\n"
"\t\t\tLogMessage(\"Private reply mode cancelled by manual destination change; previous reply target was %u\",\n"
"\t\t\t\tprivateReplyTarget);\n"
"\t\t\tprivateReplyActive = false;\n"
"\t\t\tprivateReplyTarget = 0U;\n"
"\t\t\tprivateReplySavedDstId = 0U;\n"
"\t\t\tprivateReplySavedFLCO = FLCO_GROUP;\n"
"\t\t}\n"
"\t};\n"
)
text = text.replace(old, new, 1)
# Manual Wires-X / DTMF destination changes must cancel a temporary reply.
pattern = r'(case WXS_CONNECT:\n)(\s+)(m_srcid = findYSFID\(m_ysfSrc, false\);)'
text, n = re.subn(pattern, r'\1\2cancelPrivateReply();\n\2\3', text)
if n < 2:
fail("YSF2DMR.cpp: WXS_CONNECT-Stellen nicht vollständig gefunden")
pattern = r'(case WXS_DISCONNECT:\n)(\s+)(LogMessage\("Disconnect[^\n]+\n)'
text, n = re.subn(pattern, r'\1\2cancelPrivateReply();\n\2\3', text)
if n < 2:
fail("YSF2DMR.cpp: WXS_DISCONNECT-Stellen nicht vollständig gefunden")
# While a local C4FM/YSF transmission is active, keep the reply window alive.
old = (
"\t\t\t\tunsigned char fi = fich.getFI();\n"
"\t\t\t\tunsigned char dt = fich.getDT();\n"
"\t\t\t\tunsigned char fn = fich.getFN();\n"
"\t\t\t\tunsigned char ft = fich.getFT();\n"
)
if old not in text:
fail("YSF2DMR.cpp: YSF-FICH-Block nicht gefunden")
new = old + (
"\n\t\t\t\tif (privateReplyActive &&\n"
"\t\t\t\t\t(fi == YSF_FI_HEADER || fi == YSF_FI_COMMUNICATIONS))\n"
"\t\t\t\t\tprivateReplyWatch.start();\n"
)
text = text.replace(old, new, 1)
old = "\t\t\tif (!tx_dmrdata.isMissing()) {\n\t\t\t\tnetworkWatchdog.start();\n"
if old not in text:
fail("YSF2DMR.cpp: DMR-RX-Block nicht gefunden")
private_block = (
old +
"\n\t\t\t\tconst bool privateCallToUs =\n"
"\t\t\t\t\tnetflco == FLCO_USER_USER &&\n"
"\t\t\t\t\t(DstId == m_defsrcid || DstId == m_srcHS);\n\n"
"\t\t\t\tif (autoPrivateReply && privateCallToUs) {\n"
"\t\t\t\t\tif (!privateReplyActive &&\n"
"\t\t\t\t\t\tDataType == DT_VOICE_LC_HEADER &&\n"
"\t\t\t\t\t\tDataType != m_dmrLastDT) {\n"
"\t\t\t\t\t\tprivateReplySavedDstId = m_dstid;\n"
"\t\t\t\t\t\tprivateReplySavedFLCO = m_dmrflco;\n"
"\t\t\t\t\t\tprivateReplyTarget = SrcId;\n"
"\t\t\t\t\t\tprivateReplyActive = true;\n\n"
"\t\t\t\t\t\tm_dstid = SrcId;\n"
"\t\t\t\t\t\tm_dmrflco = FLCO_USER_USER;\n\n"
"\t\t\t\t\t\tprivateReplyWatch.start();\n\n"
"\t\t\t\t\t\tLogMessage(\"Private reply mode activated: caller %u, saved destination %s%u, timeout %u seconds\",\n"
"\t\t\t\t\t\t\tprivateReplyTarget,\n"
"\t\t\t\t\t\t\tprivateReplySavedFLCO == FLCO_GROUP ? \"TG \" : \"\",\n"
"\t\t\t\t\t\t\tprivateReplySavedDstId,\n"
"\t\t\t\t\t\t\tprivateReplyTimeout);\n"
"\t\t\t\t\t}\n"
"\t\t\t\t\telse if (privateReplyActive && SrcId == privateReplyTarget) {\n"
"\t\t\t\t\t\t// Keep reply mode alive for the whole incoming private transmission.\n"
"\t\t\t\t\t\tprivateReplyWatch.start();\n"
"\t\t\t\t\t}\n"
"\t\t\t\t\telse if (privateReplyActive &&\n"
"\t\t\t\t\t\tSrcId != privateReplyTarget &&\n"
"\t\t\t\t\t\tDataType == DT_VOICE_LC_HEADER &&\n"
"\t\t\t\t\t\tDataType != m_dmrLastDT) {\n"
"\t\t\t\t\t\tLogMessage(\"Private call from %u received while reply mode is locked to %u; reply target unchanged\",\n"
"\t\t\t\t\t\t\tSrcId, privateReplyTarget);\n"
"\t\t\t\t\t}\n"
"\t\t\t\t}\n"
)
text = text.replace(old, private_block, 1)
old = "\t\tstopWatch.start();\n\n\t\tm_ysfNetwork->clock(ms);"
if old not in text:
fail("YSF2DMR.cpp: Loop-Ende nicht gefunden")
new = (
"\t\tif (privateReplyActive &&\n"
"\t\t\tprivateReplyWatch.elapsed() >= privateReplyTimeout * 1000U) {\n"
"\t\t\tLogMessage(\"Private reply mode timeout: restoring destination %s%u\",\n"
"\t\t\t\tprivateReplySavedFLCO == FLCO_GROUP ? \"TG \" : \"\",\n"
"\t\t\t\tprivateReplySavedDstId);\n\n"
"\t\t\tm_dstid = privateReplySavedDstId;\n"
"\t\t\tm_dmrflco = privateReplySavedFLCO;\n\n"
"\t\t\tprivateReplyActive = false;\n"
"\t\t\tprivateReplyTarget = 0U;\n"
"\t\t\tprivateReplySavedDstId = 0U;\n"
"\t\t\tprivateReplySavedFLCO = FLCO_GROUP;\n"
"\t\t}\n\n"
+ old
)
text = text.replace(old, new, 1)
write(path, text)
print("Patch erfolgreich eingespielt.")
PY
cd "$SRC"
make clean
make -j2
if [ ! -x "$SRC/YSF2DMR" ]; then
echo "Build fehlgeschlagen: $SRC/YSF2DMR wurde nicht erzeugt."
exit 1
fi
BIN="$(command -v YSF2DMR 2>/dev/null || true)"
if [ -z "$BIN" ]; then
BIN="/usr/local/bin/YSF2DMR"
fi
if [ -x "$BIN" ]; then
cp -a "$BIN" "$BIN.da6it-backup-$STAMP"
fi
if [ ! -f "$CONFIG" ]; then
echo "Konfiguration $CONFIG wurde nicht gefunden. Abbruch vor der Installation."
exit 1
fi
cp -a "$CONFIG" "$CONFIG.da6it-backup-$STAMP"
python3 - "$CONFIG" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
lines = path.read_text(encoding="utf-8").splitlines()
section_start = None
section_end = len(lines)
for i, line in enumerate(lines):
if line.strip().lower() == "[dmr network]":
section_start = i
continue
if section_start is not None and i > section_start and line.strip().startswith("["):
section_end = i
break
if section_start is None:
raise SystemExit("[DMR Network] wurde in /etc/ysf2dmr nicht gefunden")
wanted = {
"autoprivatereply": "AutoPrivateReply=1",
"privatereplytimeout": "PrivateReplyTimeout=30",
}
seen = set()
for i in range(section_start + 1, section_end):
stripped = lines[i].strip()
if "=" not in stripped or stripped.startswith("#"):
continue
key = stripped.split("=", 1)[0].strip().lower()
if key in wanted:
lines[i] = wanted[key]
seen.add(key)
missing = [wanted[key] for key in wanted if key not in seen]
if missing:
insert_at = section_end
lines[insert_at:insert_at] = [""] + missing
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
systemctl stop "$SERVICE" 2>/dev/null || true
install -m 755 "$SRC/YSF2DMR" "$BIN"
systemctl start "$SERVICE"
sleep 2
echo
echo "=== Konfiguration ==="
sed -n '/^\[DMR Network\]/,/^\[/p' "$CONFIG" | grep -E 'AutoPrivateReply|PrivateReplyTimeout' || true
echo
echo "=== Dienst ==="
systemctl --no-pager --full status "$SERVICE" | sed -n '1,14p' || true
echo
echo "=== Letzte YSF2DMR-Logs ==="
journalctl -u "$SERVICE" -n 30 --no-pager 2>/dev/null | grep -Ei 'Private Call Reply|Private reply|YSF2DMR' || true
echo
echo "Fertig. Backups tragen den Zeitstempel: $STAMP"
Die Beispiel-ID und die Talkgroup sind natürlich nur Platzhalter. Entscheidend ist, dass beim eingehenden Einzelruf der gespeicherte Zustand und die DMR-ID des Anrufers sichtbar werden und nach Ablauf des Timeouts die vorherige Destination wiederhergestellt wird.
Warum ein manueller Talkgroup-Wechsel den Modus beendet
Wenn während des 30-Sekunden-Fensters bewusst über Wires-X oder DTMF eine andere Talkgroup beziehungsweise ein anderes Ziel gewählt wird, soll YSF2DMR diese Entscheidung respektieren. Deshalb löscht der Patch den temporären Private-Reply-Zustand bei einem manuellen Zielwechsel. Ohne diese Absicherung könnte der Timeout später wieder auf die vor dem Private Call gespeicherte Talkgroup springen und damit eine gerade bewusst ausgewählte TG überschreiben.
Rollback: vorheriges YSF2DMR wiederherstellen
Der Installer sichert sowohl das vorherige Binary als auch /etc/ysf2dmr mit einem Zeitstempel. Wenn du zurück auf den vorherigen Stand möchtest, kann der folgende Block automatisch die jeweils neuesten DA6IT-Backups finden und wiederherstellen:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Bitte als root ausführen."
exit 1
fi
REMOUNT_RO=0
cleanup() {
if [ "$REMOUNT_RO" -eq 1 ] && command -v rpi-ro >/dev/null 2>&1; then
rpi-ro >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
if command -v rpi-rw >/dev/null 2>&1; then
rpi-rw
REMOUNT_RO=1
fi
BIN="$(command -v YSF2DMR 2>/dev/null || true)"
if [ -z "$BIN" ]; then
BIN="/usr/local/bin/YSF2DMR"
fi
CONFIG="/etc/ysf2dmr"
BIN_BACKUP="$(ls -1t "$BIN".da6it-backup-* 2>/dev/null | head -1 || true)"
CFG_BACKUP="$(ls -1t "$CONFIG".da6it-backup-* 2>/dev/null | head -1 || true)"
if [ -z "$BIN_BACKUP" ] || [ -z "$CFG_BACKUP" ]; then
echo "Kein vollständiges DA6IT-Backup gefunden."
exit 1
fi
echo "Binary-Backup: $BIN_BACKUP"
echo "Config-Backup: $CFG_BACKUP"
systemctl stop ysf2dmr.service 2>/dev/null || true
cp -a "$BIN_BACKUP" "$BIN"
cp -a "$CFG_BACKUP" "$CONFIG"
chmod 755 "$BIN"
systemctl start ysf2dmr.service
sleep 2
systemctl --no-pager --full status ysf2dmr.service | sed -n '1,14p' || true
Nach Pi-Star- oder WPSD-Updates kontrollieren
Pi-Star und WPSD verwalten YSF2DMR als Bestandteil ihres MMDVM-Software-Stacks. Ein Systemupdate kann deshalb das selbst kompilierte Binary wieder ersetzen. Wenn Private Calls nach einem Update plötzlich nicht mehr automatisch beantwortet werden, zuerst die Konfiguration und anschließend das Start-Log prüfen. Fehlt dort die Meldung Private Call Reply, läuft sehr wahrscheinlich wieder ein ungepatchtes YSF2DMR.
In diesem Fall kann der Installer erneut ausgeführt werden. Er erkennt einen bereits gepatchten Source-Tree und legt vor der Installation trotzdem wieder neue Backups des aktuell laufenden Systems an.
Technisch: warum die Antwort wirklich ein DMR Private Call wird
YSF2DMR verwendet für das DMR-Ziel unter anderem m_dstid und für den Call-Type m_dmrflco. Beim ersten passenden eingehenden Einzelruf sichert der Patch beide bisherigen Werte. Anschließend wird m_dstid auf die SrcId des DMR-Anrufers gesetzt und m_dmrflco auf FLCO_USER_USER umgestellt.
Genau dadurch wird die nächste von C4FM/YSF kommende Aussendung nicht als Gruppenruf an die bisherige Talkgroup aufgebaut, sondern als DMR User-to-User beziehungsweise Private Call an die Gegenstation. Nach dem Timeout werden die gesicherten Werte wieder eingesetzt.
Fazit
Mit dem kleinen Patch fühlt sich YSF2DMR auf Pi-Star und WPSD bei eingehenden DMR Private Calls deutlich vollständiger an. Der C4FM-Benutzer muss nicht erst die DMR-ID des Anrufers manuell als Ziel auswählen, und die zuvor verwendete Talkgroup geht trotzdem nicht verloren. Für einen MMDVM-Hotspot, der regelmäßig zwischen C4FM/YSF und DMR arbeitet, ist das eine sehr praktische Ergänzung.
