diff --git a/raspberry-pi/README.md b/raspberry-pi/README.md index bf96213bf..8d5de4bfb 100644 --- a/raspberry-pi/README.md +++ b/raspberry-pi/README.md @@ -63,13 +63,29 @@ Board profiles import `hardware.raspberry-pi.configtxt`, which renders `config.t List values become repeated keys in the rendered file, so the `dtoverlay` above expands to: -``` +```ini dtoverlay=vc4-kms-v3d dtoverlay=disable-bt ``` Top-level attrs are conditional sections (`all`, `pi4`, `pi5`, `cm4`, and so on). Nesting stacks filters. To drop a default, set the key to `null` with `mkForce`. +Use `configtxt.overlays` when order or parameter scope matters: + +```nix +{ + hardware.raspberry-pi.configtxt.overlays = [ + { + name = "dwc2"; + filters = [ "pi4" ]; + params = [ "dr_mode=host" ]; + } + ]; +} +``` + +`configtxt.settings` is rendered first. Each entry writes its filters, overlay, and parameters in order, then resets the overlay scope. + ## Current limits - No bootloader module: There's no `boot.loader.raspberry-pi` here. Boards rely on `generic-extlinux-compatible` plus U-Boot. Raspberry Pi OS has the GPU firmware load the kernel directly; we go through U-Boot so it reads `extlinux.conf`, which is what gives you the NixOS boot-generation menu and rollbacks. The firmware install module just stages the boot code and (optionally) U-Boot; it doesn't add a firmware-level direct-boot path. Pi 5 boots from SD via U-Boot, but USB, PCIe, and the RP1 don't come up until Linux takes over, so a USB keyboard at the U-Boot prompt won't work on Pi 5 today. diff --git a/raspberry-pi/common/config-txt.nix b/raspberry-pi/common/config-txt.nix index a3b5569ea..091f785f2 100644 --- a/raspberry-pi/common/config-txt.nix +++ b/raspberry-pi/common/config-txt.nix @@ -20,6 +20,27 @@ let cfg = config.hardware.raspberry-pi.configtxt; + overlayType = lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Firmware overlay name without the `.dtbo` suffix."; + }; + + params = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Ordered parameters applied to this overlay."; + }; + + filters = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Ordered config.txt conditional filters without brackets."; + }; + }; + }; + mkValueString = v: if builtins.isInt v then @@ -77,6 +98,66 @@ let in lib.concatMapStringsSep "\n[all]\n" mkGroup groups; + renderOverlay = + overlay: + lib.concatStringsSep "\n" ( + [ "[all]" ] + ++ map (filter: "[${filter}]") overlay.filters + ++ [ "dtoverlay=${overlay.name}" ] + ++ map (param: "dtparam=${param}") overlay.params + ++ [ "dtoverlay=" ] + ); + + # Render settings first, followed by ordered overlays. The final [all] clears + # any filters left by the last overlay. + settingsConfig = toConfigTxt cfg.settings; + overlayConfig = lib.pipe cfg.overlays [ + (map renderOverlay) + (overlays: overlays ++ [ "[all]" ]) + (lib.concatStringsSep "\n") + (text: text + "\n") + ]; + generatedConfig = + settingsConfig + + lib.optionalString (cfg.overlays != [ ]) ( + lib.optionalString (settingsConfig != "") "\n" + overlayConfig + ); + generatedFile = pkgs.writeText "config.txt" generatedConfig; + + # Collect invalid values so assertion messages can show the original input. + hasLineBreak = value: lib.hasInfix "\n" value || lib.hasInfix "\r" value; + validOverlayName = + name: + name != "" + && builtins.match "^[A-Za-z0-9][A-Za-z0-9._+-]*$" name != null + && !lib.hasSuffix ".dtbo" name; + validFilter = + filter: + filter != "" && !hasLineBreak filter && !lib.hasInfix "[" filter && !lib.hasInfix "]" filter; + + invalidNames = lib.pipe cfg.overlays [ + (lib.filter (overlay: !validOverlayName overlay.name)) + (map (overlay: overlay.name)) + ]; + invalidFilters = lib.pipe cfg.overlays [ + (lib.concatMap (overlay: overlay.filters)) + (lib.filter (filter: !validFilter filter)) + ]; + invalidParams = lib.pipe cfg.overlays [ + (lib.concatMap (overlay: overlay.params)) + (lib.filter hasLineBreak) + ]; + + # Raspberry Pi firmware rejects config.txt lines longer than 98 characters. + longLines = lib.pipe generatedConfig [ + (lib.splitString "\n") + (lib.filter (line: builtins.stringLength line > 98)) + ]; + + # A custom file bypasses generatedConfig. It cannot be combined with ordered + # overlays, and its lines are not validated here. + usesGeneratedFile = toString cfg.file == toString generatedFile; + in { options.hardware.raspberry-pi.configtxt = { @@ -123,14 +204,59 @@ in ''; }; + overlays = lib.mkOption { + type = lib.types.listOf overlayType; + default = [ ]; + description = '' + Ordered Raspberry Pi firmware overlays. + + Entries are rendered after {option}`settings`. Each one writes its + filters, `dtoverlay`, and `dtparam` values in order, then resets the + overlay scope. + ''; + example = lib.literalExpression '' + [ + { + name = "dwc2"; + params = [ "dr_mode=host" ]; + filters = [ "pi4" ]; + } + ] + ''; + }; + file = lib.mkOption { type = lib.types.path; - default = pkgs.writeText "config.txt" (toConfigTxt cfg.settings); - defaultText = lib.literalExpression ''pkgs.writeText "config.txt" (generated from settings)''; + default = generatedFile; + defaultText = lib.literalExpression ''pkgs.writeText "config.txt" (generated from settings and overlays)''; description = '' Path to the generated config.txt file. Defaults to the rendered output - of `settings`, but can be overridden to supply a custom config.txt. + of `settings` and `overlays`, but can be overridden to supply a custom + config.txt when `overlays` is empty. ''; }; }; + + config.assertions = [ + { + assertion = invalidNames == [ ]; + message = "Raspberry Pi config.txt has invalid overlay names: ${builtins.toJSON invalidNames}"; + } + { + assertion = invalidFilters == [ ]; + message = "Raspberry Pi config.txt has invalid overlay filters: ${builtins.toJSON invalidFilters}"; + } + { + assertion = invalidParams == [ ]; + message = "Raspberry Pi config.txt overlay parameters contain line breaks: ${builtins.toJSON invalidParams}"; + } + { + assertion = cfg.overlays == [ ] || usesGeneratedFile; + message = "Raspberry Pi config.txt overlays cannot be used with a custom configtxt.file."; + } + { + assertion = !usesGeneratedFile || longLines == [ ]; + message = "Raspberry Pi config.txt lines exceed the 98-character limit: ${builtins.toJSON longLines}"; + } + ]; }