5G NSA (EN-DC) shown as 4G/LTE on Photonicat 2: root cause in quectel-cm and pcat-manager-web, with a working fix

Started by originxl

originxl

Setup: Photonicat 2, photonicatWrt 26.04.1, pcat-manager-web 2.2.1-279, quectel-cm 1.0.3-7. Modem Quectel RM520N-GL (FW RM520NGLAAR03A03M4G, QMI). Operator Altel (Kazakhstan, 401/77): 5G NSA with an LTE B3 anchor and n78 at 100 MHz.

Symptom: the modem is on 5G NSA. AT+COPS? returns AcT 13, AT+QNWINFO lists both FDD LTE … BAND 3 and TDD NR5G … BAND 78, and I get ~150–180 Mbit/s. Despite that, the LCD and the web panel show 4G, Modem Info shows FDD LTE BAND 3, and Serving Cell / SRX Data are empty.

There are two independent causes.

1. quectel-cm (LCD, modem_mode, modem-status-get)

In requestGetSignalInfo() (QMIThread.c) the MODE=NR5G-NSA line is printed only when s_5g_type == WWAN_DATA_CLASS_5G_NSA. That value is set only when NAS SYS_INFO reports endc_available && !restrict_dcnr. endc_available reflects SIB2 upperLayerIndication-r15, and many operators do not broadcast it. Here AT+QENDC returns 0,0,0,1,0, yet the NR SCG is active. So quectel-cm logs DataCap: LTE and MODE=LTE, and pcat-manager reports "mode":"lte".

Fix I tested: run the 5G NSA block whenever s_5g_type is not SA, and print MODE=NR5G-NSA if the 0x17 (5G NSA) signal TLV is present. pcat-manager already drops 5G lines with invalid RSRP. On the 1.0.3-7 binary this is a 2-instruction patch at 0xbf9c/0xbfa0: cmp w0,#0x40; b.eq → cmp w0,#0x80; b.ne. The source equivalent in QMIThread.c is if (s_5g_type != WWAN_DATA_CLASS_5G_SA) for the 5G_NSA block.

Result after reboot:

  • the log shows requestGetSignalInfo 5G_NSA: RSRP -86 dBm and MODE=NR5G-NSA;
  • modem-status-get reports "mode":"5g";
  • the LCD shows 5G;
  • no 5G-fail auto resets in about 13 minutes of monitoring.

2. pcat-manager-web parsers (Modem Info page)

In pc_modem_client.py:

  • handle_qnwinfo() stops at the first +QNWINFO: line. In NSA that line is always the LTE anchor, so cell_tech is always 4G.
  • handle_qeng_servingcell() expects the single-line NR5G-SA layout. In NSA, "servingcell" only carries the state, and the data is on separate "LTE",… and "NR5G-NSA",… lines. The result is list index out of range and empty serving cell fields. The LTE-only single-line layout is also parsed with the SA field order, so EARFCN ends up as TAC.
  • handle_qcainfo() reads only the PCC (SCCs, including NR, are dropped). For LTE it takes <pcell_state> as PCI (PCI=1 instead of 354), and it overwrites tech with Other for LTE.

I replaced these three methods to:

  • parse all lines;
  • handle the SA, LTE and NSA layouts;
  • list all carriers.

They are byte-code identical in the public repo and in 2.2.1-279, so the fix applies as is. The panel now shows FDD LTE BAND 3 + TDD NR5G BAND 78, EN-DC: LTE B3 PCI 354 + NR n78 PCI 828 and NR/LTE signal values.

I'm happy to share the patched methods, tests with real RM520N-GL responses, and the raw AT/QMI logs if that helps.

originxl

Patches and logs, part 1 of 2: quectel-cm (LCD / modem_mode / modem-status-get).

Versions: photonicatWrt R26.04.1, kernel 6.12.91, quectel-cm 1.0.3-7, pcat-manager 2.0.0-77, pcat-manager-web 2.2.1-279. Modem RM520N-GL, FW RM520NGLAAR03A03M4G, QMI.

Source patch against package/lean/quectel-cm/src/QMIThread.c (photonicat_openwrt master). It builds cleanly with the package Makefile (-Wall -Werror):

--- a/package/lean/quectel-cm/src/QMIThread.c
+++ b/package/lean/quectel-cm/src/QMIThread.c
@@ -2221,8 +2221,9 @@
         }
     }
 
-    // 5G_NSA
-    if (s_5g_type == WWAN_DATA_CLASS_5G_NSA)
+    // 5G_NSA: endc_available (SIB2 upperLayerIndication-r15) is not broadcast by
+    // every operator, so report the NR leg whenever the 5G NSA TLV is present
+    if (s_5g_type != WWAN_DATA_CLASS_5G_SA)
     {
         PQMINAS_SIG_INFO_5G_NSA_TLV_MSG ptlv = (PQMINAS_SIG_INFO_5G_NSA_TLV_MSG)GetTLV(&pResponse->MUXMsg.QMUXMsgHdr, 0x17);
         if (ptlv && ptlv->TLVLength)

The LTE line is printed before the NSA line, so pcat-manager ends up with 5g. If the 0x17 TLV is missing, nothing changes. pcat-manager already ignores 5G lines with invalid RSRP (pcat_modem_manager_signal_report_is_valid).

What I actually run. The 1.0.3-7 source isn't public: the binary has Failed to bring up wwan_lte: %s, which is not in the 1.0.3-6 source in git. So I patched the shipped binary instead. The change is the same, 2 instructions in requestGetSignalInfo:

file offset original patched
0xbf9c 1f000171 cmp w0, #0x40 1f000271 cmp w0, #0x80
0xbfa0 601e0054 b.eq 0xc36c 611e0054 b.ne 0xc36c

md5 original 9eb9f53284fe7424e7e715c74e2f4d86, patched fbe37c353a74a7bd615635409252e15c.

Before the patch: AT+QENDC → 0,0,0,1,0, i.e. the cell does not broadcast the ENDC flag:

requestRegistrationState2 MCC: 401, MNC: 77, PS: Attached, DataCap: LTE
requestGetSignalInfo LTE: RSSI -65 dBm, RSRQ -9 dB, RSRP -92 dBm, SNR 12.4 dB
CMD=SIGNALINFO,MODE=LTE,RSSI=-65,RSRQ=-9

modem-status-get → "mode": "lte".

After the patch:

requestRegistrationState2 MCC: 401, MNC: 77, PS: Attached, DataCap: LTE
requestGetSignalInfo LTE: RSSI -58 dBm, RSRQ -10 dB, RSRP -90 dBm, SNR 13.4 dB
CMD=SIGNALINFO,MODE=LTE,RSSI=-58,RSRQ=-10
requestGetSignalInfo 5G_NSA: RSRP -94 dBm, SNR 25.5 dB
CMD=SIGNALINFO,MODE=NR5G-NSA,RSRP=-94

modem-status-get → "mode": "5g", the LCD shows 5G. I watched it for ~20 min: the mode stayed 5g, there were no 5G-fail auto resets (quectel-cm started once), and the NSA TLV was present in every poll, including at low traffic.

Modem state for reference:

AT+COPS?
+COPS: 0,0,"ALTEL",13
AT+QNWINFO
+QNWINFO: "FDD LTE","40177","LTE BAND 3",1850
+QNWINFO: "TDD NR5G","40177","NR5G BAND 78",649988
AT+QENG="servingcell"
+QENG: "servingcell","NOCONN"
+QENG: "LTE","FDD",401,77,1A3E902,354,1850,3,5,5,271E,-88,-9,-59,17,10,180,-
+QENG: "NR5G-NSA",401,77,828,-96,11,-11,649988,78,8,1
AT+QCAINFO
+QCAINFO: "PCC",1850,100,"LTE BAND 3",1,354,-88,-9,-59,15
+QCAINFO: "SCC",125,75,"LTE BAND 1",1,354,-91,-10,-72,10,0,-,-
+QCAINFO: "SCC",500,100,"LTE BAND 1",1,354,-89,-8,-73,9,0,-,-
+QCAINFO: "SCC",1652,100,"LTE BAND 3",1,354,-87,-12,-66,15,0,-,-
+QCAINFO: "SCC",649988,8,"NR5G BAND 78",828
AT+QENDC
+QENDC: 0,0,0,1,0

originxl

Patches, part 2 of 2: pcat-manager-web (app/pc_modem_client.py, Modem Info page).

These three methods are byte-code identical in the public repo (937c7ff) and in the shipped 2.2.1-279 build. I compiled both with the same Python 3.13.9 and compared them, so the changes below apply to the current build as is. I run them on 2.2.1-279 through a small wrapper module, and the rest of the panel is stock.

handle_qnwinfo and handle_qeng_servingcell, full replacements.

  • They parse all lines.
  • They handle three layouts: NR5G-SA single line, LTE single line, and NSA (servingcell + LTE + NR5G-NSA lines).
  • In NSA the serving_* fields hold the NR leg, and the LTE anchor goes into serving_lte_anchor.
  • TAC is kept as the hex string.
    def handle_qnwinfo(self, response: bytes):
        """
        Parse QNWINFO response for network information.
        Example responses:
            SA / LTE-only (one line):
                b'+QNWINFO: "TDD NR5G","46011","NR5G BAND 78",627264\r\nOK\r\n'
            EN-DC / NSA (LTE anchor first, NR secondary cell group second):
                b'+QNWINFO: "FDD LTE","40177","LTE BAND 3",1652\r\n'
                b'+QNWINFO: "TDD NR5G","40177","NR5G BAND 78",643322\r\nOK\r\n'
        All lines are parsed. If an NR line is present the tech is "5G"; when it
        comes together with an LTE line the connection is EN-DC (NSA).
        """
        if b'ERROR' in response:
            self.cmd_mark_failure('AT+QNWINFO')
            return

        entries = []
        for line in response.split(b"\r\n"):
            if not line.startswith(b"+QNWINFO:"):
                continue
            payload = line.split(b":", 1)[1].strip()
            parts = [part.strip().strip(b'"').decode("utf-8", errors="ignore") for part in payload.split(b",")]
            if len(parts) < 4:
                print("Warning: Unexpected +QNWINFO format:", line)
                continue
            entries.append(parts[:4])

        if not entries:
            return

        def tech_of(mode):
            m = mode.upper()
            if "NR" in m or "5G" in m:
                return "5G"
            if "LTE" in m or "4G" in m:
                return "4G"
            if "WCDMA" in m or "HSPA" in m or "3G" in m:
                return "3G"
            return "Other"

        def info_of(mode, band):
            words = f"{mode} {band}".strip().split(" ")
            return " ".join(dict.fromkeys(words))

        network_mode, mccmnc, band, cell_id = entries[0]
        techs = [tech_of(e[0]) for e in entries]
        nr = next((e for e in entries if tech_of(e[0]) == "5G"), None)
        lte = next((e for e in entries if tech_of(e[0]) == "4G"), None)
        nsa = nr is not None and lte is not None

        tech = "5G" if nr is not None else techs[0]
        network_info = " + ".join(info_of(e[0], e[2]) for e in entries)

        if self.is_em05() or self.is_fm350():
            self.basic["isp_details"] = lookup_isp_details(mccmnc)

        self.basic["cell_network_mode"] = "EN-DC (NSA)" if nsa else network_mode
        self.basic["cell_mccmnc"]       = mccmnc
        self.basic["cell_band"]         = " + ".join(e[2] for e in entries)
        self.basic["cell_id"]           = cell_id
        self.basic["cell_tech"]         = tech
        self.basic["cell_nr5g_nsa"]     = nsa
        self.basic["network_info"]      = network_info
        print(
            f"Parsed QNWINFO → mode={self.basic['cell_network_mode']}, "
            f"mccmnc={mccmnc}, band={self.basic['cell_band']}, cell_id={cell_id}, cell_tech={tech}"
        )

    def handle_qeng_servingcell(self, response: bytes):
        """
        Parse QENG="servingcell" response for current serving-cell info.
        Three layouts are produced by Quectel RM5xx modems:
            NR5G-SA (one line):
                +QENG: "servingcell",<state>,"NR5G-SA",<duplex>,<MCC>,<MNC>,<cellID>,<PCID>,<TAC>,<ARFCN>,<band>,<NR_DL_bw>,<RSRP>,<RSRQ>,<SINR>,<scs>,<srxlev>
            LTE only (one line):
                +QENG: "servingcell",<state>,"LTE",<is_tdd>,<MCC>,<MNC>,<cellID>,<PCID>,<earfcn>,<band>,<UL_bw>,<DL_bw>,<TAC>,<RSRP>,<RSRQ>,<RSSI>,<SINR>,<CQI>,<tx_power>,<srxlev>
            EN-DC / NSA (three lines):
                +QENG: "servingcell",<state>
                +QENG: "LTE",<is_tdd>,<MCC>,<MNC>,<cellID>,<PCID>,<earfcn>,<band>,<UL_bw>,<DL_bw>,<TAC>,<RSRP>,<RSRQ>,<RSSI>,<SINR>,<CQI>,<tx_power>,<srxlev>
                +QENG: "NR5G-NSA",<MCC>,<MNC>,<PCID>,<RSRP>,<SINR>,<RSRQ>,<ARFCN>,<band>,<NR_DL_bw>,<scs>
        """
        if b'ERROR' in response:
            self.cmd_mark_failure('AT+QENG="servingcell"')
            return

        nr_bw_map = {0:5,1:10,2:15,3:20,4:25,5:30,6:40,7:50,8:60,9:70,10:80,11:90,12:100,13:200}
        lte_bw_map = {0:1.4,1:3,2:5,3:10,4:15,5:20}
        to_int = self.convert_to_int

        def fields(line):
            payload = line.split(b":", 1)[1]
            return [p.strip().strip(b'"').decode("utf-8", errors="ignore") for p in payload.split(b",")]

        state = None
        lte = None
        nr = None
        try:
            for line in response.split(b"\r\n"):
                if not line.startswith(b"+QENG:"):
                    continue
                f = fields(line)
                if f[0] == "servingcell":
                    state = f[1] if len(f) > 1 else None
                    if len(f) > 2:
                        f = f[2:]
                    else:
                        continue
                rat = f[0]
                if rat == "LTE" and len(f) >= 18:
                    lte = {
                        "duplex": f[1], "mcc": f[2], "mnc": f[3], "cellid": f[4],
                        "pci": to_int(f[5]), "arfcn": to_int(f[6]), "band": to_int(f[7]),
                        "dl_bw_ind": to_int(f[9]), "tac": f[10],
                        "rsrp": to_int(f[11]), "rsrq": to_int(f[12]), "sinr": to_int(f[14]),
                        "srxlev": to_int(f[17]),
                    }
                    lte["bw_mhz"] = lte_bw_map.get(lte["dl_bw_ind"])
                elif rat == "NR5G-SA" and len(f) >= 15:
                    nr = {
                        "rat": rat, "duplex": f[1], "mcc": f[2], "mnc": f[3], "cellid": f[4],
                        "pci": to_int(f[5]), "tac": f[6], "arfcn": to_int(f[7]), "band": to_int(f[8]),
                        "dl_bw_ind": to_int(f[9]), "rsrp": to_int(f[10]), "rsrq": to_int(f[11]),
                        "sinr": to_int(f[12]), "scs": to_int(f[13]), "srxlev": to_int(f[14]),
                    }
                elif rat == "NR5G-NSA" and len(f) >= 11:
                    nr = {
                        "rat": rat, "duplex": "TDD" if 620000 <= to_int(f[7]) < 2016667 else "", "mcc": f[1], "mnc": f[2],
                        "cellid": None, "pci": to_int(f[3]), "tac": None, "arfcn": to_int(f[7]),
                        "band": to_int(f[8]), "dl_bw_ind": to_int(f[9]), "rsrp": to_int(f[4]),
                        "rsrq": to_int(f[6]), "sinr": to_int(f[5]), "scs": to_int(f[10]), "srxlev": -9999,
                    }
        except Exception as e:
            print(f"Error parsing QENG serving cell: {e}")
            return

        if nr is None and lte is None:
            return

        if nr is not None:
            nr["bw_mhz"] = nr_bw_map.get(nr["dl_bw_ind"])
            a = nr["arfcn"]
            nr["freq_mhz"] = a * 0.005 if a < 600_000 else (3000 + (a - 600_000) * 0.015 if a < 2_016_667 else 24250 + (a - 2_016_667) * 0.06)

        main = nr if nr is not None else lte
        rat = nr["rat"] if nr is not None else "LTE"
        cellid = (lte or main).get("cellid")
        tac = (lte or main).get("tac")
        duplex = main.get("duplex") or ""

        self.basic.update({
            "serving_state":     state,
            "serving_rat":       rat,
            "serving_duplex":    duplex,
            "serving_mcc":       main["mcc"],
            "serving_mnc":       main["mnc"],
            "serving_cellid":    cellid,
            "serving_pci":       main["pci"],
            "serving_tac":       tac,
            "serving_arfcn":     main["arfcn"],
            "serving_band":      main["band"],
            "serving_dl_bw_ind": main["dl_bw_ind"],
            "serving_bw_mhz":    f"{main['bw_mhz']} MHz" if main.get("bw_mhz") is not None else None,
            "serving_frequency": f"{main['freq_mhz']:.3f} MHz" if "freq_mhz" in main else None,
            "serving_rsrp":      f"{main['rsrp']} dBm",
            "serving_rsrq":      f"{main['rsrq']} dB",
            "serving_sinr":      f"{main['sinr']} dB",
            "serving_scs":       main.get("scs"),
            "serving_srxlev":    main["srxlev"],
            "serving_lte_anchor": lte if (nr is not None and lte is not None) else None,
        })

        def quality(c):
            return f"{fmt_num(c['sinr'], 'dB')}, {fmt_num(c['rsrq'], 'dB')}, {fmt_num(c['rsrp'], 'dBm')}, {fmt_num(c['srxlev'], 'dB')}"

        if rat == "NR5G-NSA" and lte is not None:
            self.basic["modem_serving_info"] = (
                f"EN-DC: LTE B{lte['band']} PCI {lte['pci']} + NR n{nr['band']} PCI {nr['pci']}, "
                f"Cell ID = {cellid}, TAC = {tac}"
            )
            self.basic["modem_serving_quality"] = f"NR: {quality(nr)} | LTE: {quality(lte)}"
        else:
            self.basic["modem_serving_info"] = f"{rat}  {duplex}, Cell ID = {cellid}, TAC = {tac}, PCI = {main['pci']}"
            self.basic["modem_serving_quality"] = quality(main)

        print(
            f"ServingCell → state={state}, RAT={rat}, PCI={main['pci']}, ARFCN={main['arfcn']}, "
            f"Band={main['band']}, RSRP={main['rsrp']}dBm, RSRQ={main['rsrq']}dB, SINR={main['sinr']}dB"
            + (f", LTE anchor B{lte['band']} PCI {lte['pci']}" if (nr is not None and lte is not None) else "")
        )

handle_qcainfo, diff.

  • It reads all carriers into ca_scc_list.
  • It takes the LTE PCI from the right field.
  • It no longer overwrites tech with Other for LTE.
--- a/handle_qcainfo
+++ b/handle_qcainfo
@@ -35,7 +35,9 @@
             if len(parts) < 5:
                 print("Warning: Unexpected +QCAINFO format:", line)
-                break
+                continue
 
-            component, arfcn_str, bw_ind_str, band, pci = parts[:5]
+            component, arfcn_str, bw_ind_str, band = parts[:4]
+            # LTE lines carry <pcell_state> before <PCID>; NR lines have <PCID> right after the band
+            pci = parts[5] if ("LTE" in band.upper() and len(parts) >= 6) else parts[4]
             arfcn = int(arfcn_str)
             bw_ind = int(bw_ind_str)
@@ -140,8 +142,12 @@
             freq_str = f"{freq_mhz:.0f} MHz"
 
-            # guess technology
-            tech = "5G" if "NR" in band or "5G" in band else "Other"
-
-            # store results
+            # store results (PCC only; SCCs are collected in ca_scc_list)
+            if component != "PCC":
+                self.basic.setdefault("ca_scc_list", []).append(
+                    {"band": band, "arfcn": arfcn_str, "pci": pci, "tech": tech,
+                     "bandwidth_mhz": bw_str, "frequency_mhz": freq_str}
+                )
+                continue
+            self.basic["ca_scc_list"] = []
             self.basic["ca_component"]           = component
             self.basic["ca_arfcn"]               = arfcn_str
@@ -166,3 +172,13 @@
                 f"frequency={freq_str}"
             )
-            break
+
+        scc = self.basic.get("ca_scc_list") or []
+        if scc and self.basic.get("cell_carrier_info"):
+            nr_scc = [c for c in scc if c["tech"] == "5G"]
+            lte_n = len(scc) - len(nr_scc)
+            extra = []
+            if lte_n:
+                extra.append(f"+{lte_n} LTE SCC")
+            for c in nr_scc:
+                extra.append(f"+ {c['band']} {c['frequency_mhz']} @ {c['bandwidth_mhz']}")
+            self.basic["cell_carrier_info"] = self.basic["cell_carrier_info"].split(" | ")[0] + " | " + ", ".join(extra)

Result on the device (NSA):

network_info       = FDD LTE BAND 3 + TDD NR5G BAND 78
cell_tech          = 5G
cell_network_mode  = EN-DC (NSA)
modem_serving_info = EN-DC: LTE B3 PCI 354 + NR n78 PCI 828, Cell ID = 1A3E90C, TAC = 271E
cell_carrier_info  = Primary CC, 1850 MHz @ 20 MHz BW | +3 LTE SCC, + NR5G BAND 78 3650 MHz @ 100 MHz

With NSA disabled (LTE only) the output is FDD LTE BAND 3, 4G, LTE FDD, Cell ID = 1A3E90C, TAC = 271E, PCI = 354. For the SA example from the docstrings the output is unchanged except that TAC is now a string. I also have 8 unittest cases built on real RM520N-GL responses (NSA, LTE only, SA) and can post them if useful.

Side note: with the quectel-cm fix from part 1, modem_mode is already correct. Without it, modem_mode in get_dashboard_data() / status2_json_action() could prefer usable_cell_tech(cell_tech) when it equals "5G".

Want to reply to this topic?

Sign in to participate

Don't have an account? Sign up