From 46da9df9a6faa50462e2691ba08822de49db6e6b Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:29:44 +0200 Subject: [PATCH 1/7] fix(headless): drop mandatory fs/pyfatfs deps for --nogui The NON-HLOS.bin FAT browser is GUI-only, yet controller.py imported it unconditionally just to resolve the DTGUIController base class. That pulled fs/pyfatfs into every headless run and made --nogui fail to import on minimal sysroots (Yocto native, CI) that have no reason to ship those packages. Import it lazily and stub the base class when absent. Signed-off-by: Igor Opaniuk --- controller.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/controller.py b/controller.py index 6c836b9..add04c3 100644 --- a/controller.py +++ b/controller.py @@ -66,7 +66,20 @@ import Autocmd as cmd #nhlos parser lib -import non_hlos_parser +# +# The NON-HLOS.bin parser is only used by the GUI's FAT image browser +# (DTGUIController instantiated via the `else` branch in run()). Its +# transitive deps (`fs`, `pyfatfs`) are unnecessary for headless +# --nogui usage such as xbl_config.elf modification, so import lazily +# and fall back to a stub when those packages are not installed. The +# stub satisfies the DTGUIController class-definition contract without +# ever being instantiated under --nogui. +try: + import non_hlos_parser +except ImportError: + class non_hlos_parser: # type: ignore + class nhlos_Operator: + pass import get_qsahara_files From 0e9cec3c24a7025e0e381657dd1b67963818323b Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:30:18 +0200 Subject: [PATCH 2/7] fix(headless): allow --nogui to run without Tcl/Tk QDTE imports tkinter at module load across many files and defines widget subclasses at class-definition time, so merely importing the controller requires Tcl/Tk -- even when no window is ever created. That made --nogui unusable on headless hosts (Yocto native sysroots, CI containers) where Tk is not present. Install a permissive tkinter stub before any QDTE module loads when --nogui is requested; GUI mode is untouched. Signed-off-by: Igor Opaniuk --- run.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/run.py b/run.py index fb3767d..b35d4ce 100644 --- a/run.py +++ b/run.py @@ -14,6 +14,45 @@ decide upon an appropriate tool to use and set up the output profile file, etc. """ import sys + +# When invoked in --nogui mode, QDTE never instantiates any tkinter +# widgets -- the controller.run() nogui branch only uses DTWrapper and +# Autocmd. Install a permissive stub for `tkinter` (and its submodules) +# before anything else loads so that the pervasive top-level +# `import tkinter` / `from tkinter import E` / `class X(tk.Frame)` +# statements throughout QDTE succeed in environments where Tcl/Tk is +# not available, such as minimal Yocto native sysroots. +if '--nogui' in sys.argv: + import types + + class _TkStub: + """Pretends to be any tkinter symbol -- class, constant, callable. + Subclassable (so `class Foo(tk.Frame)` works), callable (so + `tk.Tk()` works if it ever runs), attribute-polymorphic.""" + def __init__(self, *args, **kwargs): + pass + def __getattr__(self, name): + return _TkStub + def __call__(self, *args, **kwargs): + return _TkStub() + def __class_getitem__(cls, item): + return _TkStub + + def _make_stub_module(name): + mod = types.ModuleType(name) + mod.__path__ = [] # mark as package so submodule imports resolve + # PEP 562: __getattr__ on a module handles `from import X` + # for unknown names by returning the stub class. + mod.__getattr__ = lambda n: _TkStub + return mod + + sys.modules['tkinter'] = _make_stub_module('tkinter') + for _sub in ('font', 'ttk', 'colorchooser', 'messagebox', + 'filedialog', 'simpledialog', 'scrolledtext'): + _stub = _make_stub_module('tkinter.' + _sub) + sys.modules['tkinter.' + _sub] = _stub + setattr(sys.modules['tkinter'], _sub, _stub) + import argparse import tkinter as tk import flags as gflags From 6f2c5da6825974747d794e489ae1f91f4ae180f7 Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:30:34 +0200 Subject: [PATCH 3/7] fix(headless): survive the QUTS probe on Linux flags.global_info only defines the "quts2" install path on Windows, but the startup probe dereferenced it unconditionally. On Linux that raised KeyError before any QDTE logic ran, so the tool could not start at all. Check for the key before using it. Signed-off-by: Igor Opaniuk --- controller.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/controller.py b/controller.py index add04c3..e4c9850 100644 --- a/controller.py +++ b/controller.py @@ -86,9 +86,12 @@ class nhlos_Operator: QUTS_STATE = None quts_path = None +# The "quts2" key is only present in the Windows branch of +# flags.py:global_info (see the platform check there). Guard the +# lookup so the Linux startup path does not raise KeyError. if os.path.exists(gl_info["quts"]): quts_path = gl_info["quts"] -elif os.path.exists(gl_info["quts2"]): +elif "quts2" in gl_info and os.path.exists(gl_info["quts2"]): quts_path = gl_info["quts2"] if quts_path and os.path.exists(os.path.join(quts_path,'Common','ttypes.py'))\ and os.path.exists(os.path.join(quts_path,'ImageManagementService','ImageManagementService.py'))\ From 8f2b78f833cd211fe7f94c98b826242906c86f53 Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:30:57 +0200 Subject: [PATCH 4/7] fix(dtwrapper): support upstream pyfdt's zero-arg to_dtb() QDTE was written against an internal pyfdt fork whose to_dtb() takes an out-dict of node byte-offsets for the GUI hex viewer. The only published pyfdt (0.3) defines to_dtb(self), so every consumer outside Qualcomm's toolchain hit a TypeError on the first DTB save. The offsets are a GUI-only convenience and unused headless, so fall back to the zero-arg call when the dict argument is rejected. Signed-off-by: Igor Opaniuk --- dtwrapper.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/dtwrapper.py b/dtwrapper.py index 9071185..f0faf58 100644 --- a/dtwrapper.py +++ b/dtwrapper.py @@ -1006,7 +1006,18 @@ def __init__(self): def _update_dtb(self): self.dtb_mappings = {} if self._fdt is not None: - self.dtb = self._fdt.to_dtb(self.dtb_mappings) + # Upstream PyPI pyfdt (0.3, the only released version) exposes + # `to_dtb(self)` with no parameters. QDTE was developed against + # an internal fork whose `to_dtb()` accepts an out-dict that gets + # populated with byte-offset mappings for the GUI hex viewer. + # Probe both signatures so we work with either: pass the + # mappings dict if accepted, otherwise call the zero-arg form + # and leave `dtb_mappings` empty (the hex viewer simply will + # not highlight node offsets in that case). + try: + self.dtb = self._fdt.to_dtb(self.dtb_mappings) + except TypeError: + self.dtb = self._fdt.to_dtb() else: self.dtb = None From 16fc0c9c60a103015b08519e194e2f236fc5dfd6 Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:31:14 +0200 Subject: [PATCH 5/7] fix(dtwrapper): accept hex/oct/bin literals in --modify word values FdtPropertyWords edits cast each token with int(val), which only accepts decimal. Callers naturally pass 0x.. literals when mirroring fdtdump output or packing big-endian uint32 blobs (e.g. certificate injection), and those raised ValueError. int(val, 0) keeps decimal working while auto-detecting a prefixed base. Signed-off-by: Igor Opaniuk --- dtwrapper.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dtwrapper.py b/dtwrapper.py index f0faf58..e75fc35 100644 --- a/dtwrapper.py +++ b/dtwrapper.py @@ -592,7 +592,12 @@ def apply(self, fdt): for val in self.val_new: #dtlogger.debug(type(val)) if isinstance(val,type('')): - vals.append(int(val)) + # int(val, 0) auto-detects the base from a literal + # prefix ('0x' hex, '0o' octal, '0b' binary, else + # decimal). Plain int(val) only accepts decimal and + # rejects '0x...' tokens that callers naturally + # supply when mirroring fdtdump output. + vals.append(int(val, 0)) else: vals.append(val) #dtlogger.debug("new list is %s"%vals) From ba846e3f1b20a7d32bde42dcbf72da0907afa56f Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:31:30 +0200 Subject: [PATCH 6/7] fix(gui): launch the GUI on Linux and macOS GUI startup hid the console window through ctypes.windll, which exists only on Windows. On other platforms the attribute access raised AttributeError and the window never opened, so the GUI was effectively Windows-only. Skip the call when windll is unavailable. Signed-off-by: Igor Opaniuk --- controller.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/controller.py b/controller.py index e4c9850..0632457 100644 --- a/controller.py +++ b/controller.py @@ -1712,7 +1712,11 @@ def run(root, **kwargs): return """ else: import ctypes - ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0) + # ctypes.windll exists only on Windows; this call hides the console + # window behind the GUI there. Guard it so the GUI also launches on + # Linux/macOS, where windll is absent (AttributeError otherwise). + if hasattr(ctypes, "windll"): + ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0) DTGUIController(root, **kwargs) # start processing events From 3f0f191c1cd2df7d0eaa66d1f0f95d95c86f8c22 Mon Sep 17 00:00:00 2001 From: Igor Opaniuk Date: Fri, 19 Jun 2026 12:34:07 +0200 Subject: [PATCH 7/7] ci: add headless --nogui smoke tests The --nogui command-line flow is what downstream automation (Yocto image builds, signing pipelines) relies on, yet nothing guarded it, and the planned GUI/headless split is intrusive enough to break it silently. Exercise run.py --nogui end to end on real xbl_config.elf and uefi_dtbs.elf containers and read the edited property back from the rebuilt ELF with fdtdump, so a broken contract fails CI before it reaches consumers. Binaries come from public Qualcomm Software Center archives and QA artifactory. Signed-off-by: Igor Opaniuk --- .github/workflows/nogui-smoke.yml | 147 ++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/workflows/nogui-smoke.yml diff --git a/.github/workflows/nogui-smoke.yml b/.github/workflows/nogui-smoke.yml new file mode 100644 index 0000000..ed2f67e --- /dev/null +++ b/.github/workflows/nogui-smoke.yml @@ -0,0 +1,147 @@ +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause-Clear +# +# Headless (--nogui) integration smoke tests. +# +# These exercise the command-line contract that downstream automation +# (Yocto, signing pipelines) relies on, by invoking run.py externally on +# real Qualcomm boot binaries and checking the produced ELF actually +# contains the edited property: +# +# run.py --nogui --allow_unsigned --input_file +# --output_path --output_file +# --modify "/=" +# +# Two containers are covered: +# * xbl_config.elf - reassembled via the unsigned GenXBLConfig path +# (no sectools required) +# * uefi_dtbs.elf - a pure-DTB (.dtbs) ELF whose wrapper is rebuilt via +# `sectools elf-tool generate` (sectools required) +# +# All binaries are public "test-device" boot images / public security tools +# fetched from Qualcomm Software Center; nothing proprietary is committed. + +name: QDTE nogui smoke + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +env: + # Public boot-binary / tools archives. Bump the build numbers here to + # refresh the fixtures; the embedded DTB names below are tied to them. + XBL_ZIP_URL: "https://softwarecenter.qualcomm.com/nexus/generic/product/chip/tech-package/QCM6490_bootbinaries.1.0/qcm6490_bootbinaries.1.0-test-device-public/00126/QCM6490_bootbinaries_00126.zip" + XBL_ELF_IN_ZIP: "QCM6490_bootbinaries/xbl_config.elf" + # The first DTB embedded in the reassembled ELF (by file offset); this is + # the one `fdtdump --scan` decodes, so the verified edit targets it. + XBL_DTB_NAME: "pre-ddr-kodiak-1.0.dtb" + + UEFI_ZIP_URL: "https://softwarecenter.qualcomm.com/nexus/generic/product/chip/tech-package/QRB2210_bootbinaries.1.0/qrb2210_bootbinaries.1.0-test-device-public/00010/Agatti_bootbinaries.zip" + UEFI_ELF_IN_ZIP: "Agatti_bootbinaries/uefi_dtbs.elf" + UEFI_DTB_NAME: "qcom,uefi-agatti-1.0.dtb" + + # Public Qualcomm Security Tools (sectools). The download URL 301-redirects, + # so curl is invoked with -L. ubuntu-latest runners are x86_64 -> Linux/. + SECTOOLS_ZIP_URL: "https://softwarecenter.qualcomm.com/api/download/software/tools/Qualcomm_Security_Tools/All/1.48.0/1.48.zip" + SECTOOLS_IN_ZIP: "1.48/Linux/sectools" + + FIXTURES_DIR: "${{ github.workspace }}/.smoke-fixtures" + +jobs: + nogui-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + # QDTE headless runtime deps only. With the headless import guards + # in place, --nogui needs neither tkinter nor fs/pyfatfs. + python -m pip install --upgrade pip + python -m pip install pyfdt six + # fdtdump (verification) lives in device-tree-compiler. + sudo apt-get update + sudo apt-get install -y device-tree-compiler + + - name: Cache test binaries + id: cache-fixtures + uses: actions/cache@v4 + with: + path: ${{ env.FIXTURES_DIR }} + key: smoke-fixtures-${{ hashFiles('.github/workflows/qcom-nogui-smoke.yml') }} + + - name: Fetch test binaries + if: steps.cache-fixtures.outputs.cache-hit != 'true' + run: | + set -euo pipefail + mkdir -p "$FIXTURES_DIR" + tmp="$(mktemp -d)" + + echo "::group::xbl_config.elf" + curl -fsSL -o "$tmp/xbl.zip" "$XBL_ZIP_URL" + unzip -o -j "$tmp/xbl.zip" "$XBL_ELF_IN_ZIP" -d "$FIXTURES_DIR" + echo "::endgroup::" + + echo "::group::uefi_dtbs.elf" + curl -fsSL -o "$tmp/uefi.zip" "$UEFI_ZIP_URL" + unzip -o -j "$tmp/uefi.zip" "$UEFI_ELF_IN_ZIP" -d "$FIXTURES_DIR" + echo "::endgroup::" + + echo "::group::sectools" + curl -fsSL -o "$tmp/sectools.zip" "$SECTOOLS_ZIP_URL" + unzip -o -j "$tmp/sectools.zip" "$SECTOOLS_IN_ZIP" -d "$FIXTURES_DIR/sectools" + chmod +x "$FIXTURES_DIR/sectools/sectools" + echo "::endgroup::" + + rm -rf "$tmp" + ls -l "$FIXTURES_DIR" "$FIXTURES_DIR/sectools" + + # Each smoke test runs run.py --nogui to edit one property, then reads + # it back from the produced ELF with `fdtdump --scan`, which locates and + # decodes the embedded DTB. The expected value is matched as the 8-digit + # hex word fdtdump prints (e.g. <0x00000007>). + + - name: nogui smoke - xbl_config.elf + run: | + set -euo pipefail + # xbl_config.elf reassembles via the unsigned GenXBLConfig path + # (no sectools). The hex literal also exercises the int(val, 0) fix. + out="$(mktemp -d)" + python run.py --nogui --allow_unsigned \ + --input_file "$FIXTURES_DIR/xbl_config.elf" \ + --output_path "$out" --output_file xbl_config.elf \ + --modify "$XBL_DTB_NAME/soc/ddr_common_dt/ddr_log_level_dt=0x7" + test -f "$out/xbl_config.elf" + fdtdump --scan "$out/xbl_config.elf" 2>/dev/null \ + | grep -q "ddr_log_level_dt = <0x00000007>" + echo "PASS: xbl_config.elf ddr_log_level_dt == 0x7" + + - name: nogui smoke - uefi_dtbs.elf + run: | + set -euo pipefail + # uefi_dtbs.elf is a pure-DTB (.dtbs) ELF whose wrapper is rebuilt + # via `sectools elf-tool generate`, so --sectools_dir is required. + out="$(mktemp -d)" + python run.py --nogui --allow_unsigned \ + --sectools_dir "$FIXTURES_DIR/sectools" \ + --input_file "$FIXTURES_DIR/uefi_dtbs.elf" \ + --output_path "$out" --output_file uefi_dtbs.elf \ + --modify "$UEFI_DTB_NAME/soc/quantum_dt/enter_quantum=0x1" + test -f "$out/uefi_dtbs.elf" + fdtdump --scan "$out/uefi_dtbs.elf" 2>/dev/null \ + | grep -q "enter_quantum = <0x00000001>" + echo "PASS: uefi_dtbs.elf enter_quantum == 0x1" + + echo "All nogui smoke tests passed."