From dfa7320a37d9ffa32724a001530512ac6b5feeba Mon Sep 17 00:00:00 2001 From: NeilClack Date: Tue, 25 Aug 2026 21:40:56 -0500 Subject: [PATCH] Test 1 - EDID Spoofing for Android 4K --- android-4k30-edid.bin | Bin 0 -> 256 bytes donor.bin | Bin 0 -> 256 bytes edid-override.nix | 74 +++++++++++++++++++++ flake.nix | 26 +++++++- force_4k30_portrait_only.py | 128 ++++++++++++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 android-4k30-edid.bin create mode 100644 donor.bin create mode 100644 edid-override.nix create mode 100644 force_4k30_portrait_only.py diff --git a/android-4k30-edid.bin b/android-4k30-edid.bin new file mode 100644 index 0000000000000000000000000000000000000000..5ea79139eb166e9e14dfaa39d0fde1c05d03d58e GIT binary patch literal 256 zcmZSh4+acDTO`#O85kH$l^B^D1UM_Wcuy_}cb}my4 z_yidkk14^KQKMWzIA*DW:e), in case the real link reports + disconnected/garbled state through a splitter or wireless kit. + ''; + }; + }; + }; +in +{ + options.services.kioskEdid = { + enable = mkEnableOption "forcing custom EDIDs on one or more display connectors"; + + overrides = mkOption { + type = types.listOf overrideModule; + default = [ ]; + description = '' + List of per-connector EDID overrides to apply. Each entry gets its + own firmware file and its own drm.edid_firmware kernel param. + ''; + example = literalExpression '' + [ + { connector = "HDMI-A-1"; edidFile = ./android-4k30-edid.bin; } + { connector = "HDMI-A-2"; edidFile = ./android-4k30-edid.bin; } + ] + ''; + }; + }; + + config = mkIf cfg.enable { + hardware.firmware = map + (o: pkgs.runCommand "kiosk-edid-firmware-${o.connector}" { } '' + mkdir -p $out/lib/firmware/edid + cp ${o.edidFile} $out/lib/firmware/edid/${o.connector}.bin + '') + cfg.overrides; + + boot.kernelParams = flatten (map + (o: + [ "drm.edid_firmware=${o.connector}:edid/${o.connector}.bin" ] + ++ optional o.forceConnected "video=${o.connector}:e" + ) + cfg.overrides); + }; +} diff --git a/flake.nix b/flake.nix index 8d082ab..1acb204 100644 --- a/flake.nix +++ b/flake.nix @@ -216,7 +216,18 @@ hostName = "radiantwavekiosk-android"; diskModule = ./disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git#radiantwavekiosk-android"; - extraModules = [./rotated_display.nix]; + extraModules = [ + ./rotated_display.nix + ./edid-override.nix + { services.kioskEdid = { + enable = true; + overrides = [ + { connector = "HDMI-A-1"; edidFile = ./android-4k30-edid.bin; } + { connector = "HDMI-A-2"; edidFile = ./android-4k30-edid.bin; } + ]; + }; + } + ]; }; radiantwavekiosk-android-dev = mkKioskSystem { system = "x86_64-linux"; @@ -224,7 +235,18 @@ hostName = "radiantwavekiosk-android-dev"; diskModule = ./disk-config.nix; upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git?ref=dev#radiantwavekiosk-android-dev"; - extraModules = [./rotated_display.nix]; + extraModules = [ + ./rotated_display.nix + ./edid-override.nix + { services.kioskEdid = { + enable = true; + overrides = [ + { connector = "HDMI-A-1"; edidFile = ./android-4k30-edid.bin; } + { connector = "HDMI-A-2"; edidFile = ./android-4k30-edid.bin; } + ]; + }; + } + ]; }; # Raspberry Pi 5. radiantwavekiosk-rpi = mkRpiKioskSystem { diff --git a/force_4k30_portrait_only.py b/force_4k30_portrait_only.py new file mode 100644 index 0000000..63c718a --- /dev/null +++ b/force_4k30_portrait_only.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Forces 2160x3840 portrait @ 30Hz as the ONLY Detailed Timing Descriptor +(DTD slot 1 / preferred timing) and strips the Video Data Block from the +CTA-861 extension so no other video mode is advertised in any way. + +Usage: + python3 force_4k30_portrait_only.py donor.bin fixed.bin +""" + +import sys + + +def build_dtd(pixel_clock_khz, h_active, h_blank, h_sync_offset, h_sync_width, + v_active, v_blank, v_sync_offset, v_sync_width, + h_size_mm=0, v_size_mm=0, h_border=0, v_border=0, + interlaced=False, hsync_pos=True, vsync_pos=True): + pc = round(pixel_clock_khz / 10) # EDID stores pixel clock in 10kHz units + dtd = bytearray(18) + dtd[0] = pc & 0xFF + dtd[1] = (pc >> 8) & 0xFF + + dtd[2] = h_active & 0xFF + dtd[3] = h_blank & 0xFF + dtd[4] = ((h_active >> 8) << 4) | (h_blank >> 8) + + dtd[5] = v_active & 0xFF + dtd[6] = v_blank & 0xFF + dtd[7] = ((v_active >> 8) << 4) | (v_blank >> 8) + + dtd[8] = h_sync_offset & 0xFF + dtd[9] = h_sync_width & 0xFF + dtd[10] = ((v_sync_offset & 0xF) << 4) | (v_sync_width & 0xF) + dtd[11] = (((h_sync_offset >> 8) & 0x3) << 6) | \ + (((h_sync_width >> 8) & 0x3) << 4) | \ + (((v_sync_offset >> 4) & 0x3) << 2) | \ + ((v_sync_width >> 4) & 0x3) + + dtd[12] = h_size_mm & 0xFF + dtd[13] = v_size_mm & 0xFF + dtd[14] = ((h_size_mm >> 8) << 4) | (v_size_mm >> 8) + + dtd[15] = h_border + dtd[16] = v_border + + flags = 0x18 # digital separate sync + if interlaced: + flags |= 0x80 + if hsync_pos: + flags |= 0x02 + if vsync_pos: + flags |= 0x04 + dtd[17] = flags + + return dtd + + +TAG_VIDEO = 2 + + +def strip_video_data_block(ext): + """Remove all Video Data Blocks from a CTA-861 extension block.""" + dtd_offset = ext[2] + pos = 4 # skip 4-byte CTA header + blocks = [] + while pos < dtd_offset: + tag = (ext[pos] >> 5) & 0x07 + length = ext[pos] & 0x1F + block_data = bytes(ext[pos + 1:pos + 1 + length]) + blocks.append((tag, length, block_data)) + pos += 1 + length + + blocks = [(t, l, d) for t, l, d in blocks if t != TAG_VIDEO] + + new_db = bytearray() + for tag, length, data in blocks: + new_db.append((tag << 5) | length) + new_db.extend(data) + + new_ext = bytearray(128) + new_ext[0] = ext[0] # CTA tag + new_ext[1] = ext[1] # revision + new_ext[2] = 4 + len(new_db) # new DTD offset + new_ext[3] = ext[3] # flags (audio, YCbCr, etc.) + new_ext[4:4 + len(new_db)] = new_db + + new_ext[0x7F] = 0 + new_ext[0x7F] = (-sum(new_ext)) % 256 + return new_ext + + +if __name__ == "__main__": + infile, outfile = sys.argv[1], sys.argv[2] + data = bytearray(open(infile, "rb").read()) + + # --- 2160x3840 portrait @ 30Hz --- + # H blanking uses CVT-RB-style fixed values (front 48 / sync 32 / back 80) + # V blanking computed to satisfy CVT-RB min vertical blank time (~460us) + # Pixel clock ~= htotal * vtotal * refresh + dtd_4k30 = build_dtd( + pixel_clock_khz=271020, + h_active=2160, h_blank=160, h_sync_offset=48, h_sync_width=32, + v_active=3840, v_blank=54, v_sync_offset=3, v_sync_width=5, + h_size_mm=685, v_size_mm=1218, # swapped panel physical size for portrait + ) + + # Slot 1 = bytes 54-71 (was 1920x1080@60, the preferred timing) + data[54:72] = dtd_4k30 + + # Slot 2 = bytes 72-89 (was 1280x720@60) - blank out to a dummy + # descriptor so no second timing is advertised at all. + # Dummy Descriptor format: 00 00 00 10 00, then 13 zero-padding bytes. + dummy = bytearray(18) + dummy[3] = 0x10 # tag byte for "Dummy Descriptor" + data[72:90] = dummy + + # Unknown Display Descriptor slot and Product Name descriptor are + # left as-is. + + # Recompute base block checksum (byte 127) + data[127] = (256 - sum(data[0:127]) % 256) % 256 + + # Strip Video Data Block from CTA-861 extension (bytes 128-255) + data[128:256] = strip_video_data_block(data[128:256]) + + open(outfile, "wb").write(data) + print(f"Wrote {outfile}") + print("Run `edid-decode` on the output to verify before flashing.")