Hinweis: Config-Snippets vor dem Einsatz prüfen und an die eigene Umgebung anpassen.

YSF2DMR: Automatically Reply to DMR Private Calls from C4FM

Copy-and-paste YSF2DMR patch for Pi-Star and WPSD: reply to incoming DMR Private Calls from C4FM and automatically return to the previous talkgroup.

C4FMDMRMMDVMYSF2DMRPi-Star · WPSD
YSF2DMR: Automatically Reply to DMR Private Calls from C4FM

YSF2DMR makes it easy to bridge C4FM/YSF and DMR on Pi-Star, WPSD and other MMDVM-based hotspots. Incoming DMR Private Calls, however, expose a small usability gap: the call can arrive on the C4FM side, while a local reply would normally continue to use the previously selected DMR destination.

This patch adds an automatic Private-Call reply window. YSF2DMR saves the current destination, temporarily switches to the caller’s DMR ID using FLCO_USER_USER, and routes local C4FM transmissions back as a DMR Private Call. After 30 seconds of inactivity, the previous talkgroup or destination is restored automatically.

Flow: active TG → incoming DMR Private Call → C4FM reply automatically goes back to the caller → 30 seconds of inactivity → return to the previous TG.

What the patch does

  • Detects an incoming DMR Private Call addressed to the local DMR/hotspot ID.
  • Saves the current DMR destination and call type.
  • Temporarily uses the caller’s DMR ID as the reply target.
  • Sends local C4FM/YSF traffic as a DMR Private Call during the reply window.
  • Keeps the window alive while either side of the conversation is active.
  • Restores the previous destination after 30 seconds of inactivity.
  • Cancels the temporary state immediately after a deliberate Wires-X or DTMF destination change.

Copy & paste installer for Pi-Star and WPSD

The installer searches common Pi-Star/WPSD YSF2DMR source locations first. If no source tree exists, it checks out MMDVM_CM, patches Conf.h, Conf.cpp and YSF2DMR.cpp, rebuilds YSF2DMR, backs up the current binary and /etc/ysf2dmr, enables the feature and restarts ysf2dmr.service.

YSF2DMR Private Call patch – complete installer
#!/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"

Rollback

The installer creates timestamped backups before replacing the runtime binary or configuration. The following helper restores the newest pair of DA6IT backups and restarts YSF2DMR.

Rollback YSF2DMR Private Call patch
#!/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

Updates

Pi-Star or WPSD updates may replace a locally compiled /usr/local/bin/YSF2DMR. If Private Call auto-reply stops working after an update, check the startup log for Private Call Reply. If the line is missing, rebuild and install the patched binary again.

Result

The patch makes incoming DMR Private Calls much more natural on a C4FM/YSF hotspot: answer directly from the Yaesu radio, keep the temporary user-to-user route while the QSO is active, and let YSF2DMR automatically return to the previous talkgroup afterwards.