diff --git a/.gitea/workflows/build-dev.yml b/.gitea/workflows/build-dev.yml index dd685ba..6b8bc7d 100644 --- a/.gitea/workflows/build-dev.yml +++ b/.gitea/workflows/build-dev.yml @@ -1,51 +1,91 @@ -name: Build Dev ISO - +name: Build Dev ISOs on: workflow_dispatch: push: branches: - dev - env: NIX_CONFIG: | extra-substituters = https://attic.radiantwavetech.com/radiantwave-dev extra-trusted-public-keys = radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0= - jobs: - build-dev-iso: + build-dev-kiosk: runs-on: build-server - steps: - name: Clone Repository run: git clone --depth 1 --no-single-branch ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . - - name: Checkout dev branch run: git checkout dev - - name: Prepare output directory run: mkdir -p /home/user/radiantwave - - # --- Kiosk ISO --- - - name: Build kiosk ISO + - name: Build dev kiosk ISO run: | export NIXPKGS_ALLOW_UNFREE=1 . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L --impure .#nixosConfigurations.dev-iso.config.system.build.isoImage - - - name: Rename kiosk ISO + nix build -L --impure .#nixosConfigurations.kiosk-iso.config.system.build.isoImage + - name: Rename dev kiosk ISO run: | ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) - FILENAME="radiantwave-kiosk-dev-iso-${ISO_DATE}.iso" + FILENAME="radiantwave-kiosk-dev-${ISO_DATE}.iso" cp -L result/iso/*.iso "/home/user/radiantwave/${FILENAME}" - echo "KIOSK_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - - name: Move old dev images to archive - run: tools/archive-images.sh nclack@157.245.124.103 /mnt/infrastructure_data/files/images /mnt/infrastructure_data/files/images/archives 'radiantwave-kiosk-dev*' - - - name: Copy kiosk ISO to fileserver + echo "FILENAME=${FILENAME}" >> "$GITHUB_ENV" + - name: Copy dev kiosk ISO to fileserver run: | - scp "/home/user/radiantwave/${KIOSK_ISO_FILENAME}" \ + scp "/home/user/radiantwave/${FILENAME}" \ radiantwave-infra:/mnt/infrastructure_data/files/images/ + - name: Cleanup + run: rm -f "/home/user/radiantwave/${FILENAME}" - - name: Cleanup kiosk ISO - run: rm -f "/home/user/radiantwave/${KIOSK_ISO_FILENAME}" + build-dev-android: + runs-on: build-server + steps: + - name: Clone Repository + run: git clone --depth 1 --no-single-branch ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . + - name: Checkout dev branch + run: git checkout dev + - name: Prepare output directory + run: mkdir -p /home/user/radiantwave + - name: Build dev android ISO + run: | + export NIXPKGS_ALLOW_UNFREE=1 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + nix build -L --impure .#nixosConfigurations.android-iso.config.system.build.isoImage + - name: Rename dev android ISO + run: | + ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) + FILENAME="radiantwave-android-dev-${ISO_DATE}.iso" + cp -L result/iso/*.iso "/home/user/radiantwave/${FILENAME}" + echo "FILENAME=${FILENAME}" >> "$GITHUB_ENV" + - name: Copy dev android ISO to fileserver + run: | + scp "/home/user/radiantwave/${FILENAME}" \ + radiantwave-infra:/mnt/infrastructure_data/files/images/ + - name: Cleanup + run: rm -f "/home/user/radiantwave/${FILENAME}" + + build-dev-commercial: + runs-on: build-server + steps: + - name: Clone Repository + run: git clone --depth 1 --no-single-branch ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/KioskConfig.git . + - name: Checkout dev branch + run: git checkout dev + - name: Prepare output directory + run: mkdir -p /home/user/radiantwave + - name: Build dev commercial ISO + run: | + export NIXPKGS_ALLOW_UNFREE=1 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh + nix build -L --impure .#nixosConfigurations.commercial-iso.config.system.build.isoImage + - name: Rename dev commercial ISO + run: | + ISO_DATE=$(date +%Y-%m-%d_%H-%M-%S) + FILENAME="radiantwave-commercial-dev-${ISO_DATE}.iso" + cp -L result/iso/*.iso "/home/user/radiantwave/${FILENAME}" + echo "FILENAME=${FILENAME}" >> "$GITHUB_ENV" + - name: Copy dev commercial ISO to fileserver + run: | + scp "/home/user/radiantwave/${FILENAME}" \ + radiantwave-infra:/mnt/infrastructure_data/files/images/ + - name: Cleanup + run: rm -f "/home/user/radiantwave/${FILENAME}" diff --git a/.gitea/workflows/build-prod-android.yml b/.gitea/workflows/build-prod-android.yml index 7b4912f..3770704 100644 --- a/.gitea/workflows/build-prod-android.yml +++ b/.gitea/workflows/build-prod-android.yml @@ -33,10 +33,6 @@ jobs: cp -L result/iso/*.iso "/home/user/radiantwave/${FILENAME}" echo "ANDROID_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - # - name: Move old android images to archive - # run: | - # ssh nclack@157.245.124.103 'mv /mnt/infrastructure_data/files/images/radiantwave-android-iso* /mnt/infrastructure_data/files/images/archives/' - - name: Copy android kiosk ISO to fileserver run: | scp "/home/user/radiantwave/${ANDROID_ISO_FILENAME}" \ diff --git a/.gitea/workflows/build-prod-commercial.yml b/.gitea/workflows/build-prod-commercial.yml index d130db4..59134d1 100644 --- a/.gitea/workflows/build-prod-commercial.yml +++ b/.gitea/workflows/build-prod-commercial.yml @@ -33,10 +33,6 @@ jobs: cp -L result/iso/*.iso "/home/user/radiantwave/${FILENAME}" echo "COMMERCIAL_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - # - name: Move old commercial images to archive - # run: | - # ssh nclack@157.245.124.103 'mv /mnt/infrastructure_data/files/images/radiantwave-commercial-iso* /mnt/infrastructure_data/files/images/archives/' - - name: Copy commercial kiosk ISO to fileserver run: | scp "/home/user/radiantwave/${COMMERCIAL_ISO_FILENAME}" \ diff --git a/.gitea/workflows/build-prod-kiosk.yml b/.gitea/workflows/build-prod-kiosk.yml index 8a5d69d..e5e89ef 100644 --- a/.gitea/workflows/build-prod-kiosk.yml +++ b/.gitea/workflows/build-prod-kiosk.yml @@ -33,10 +33,6 @@ jobs: cp -L result/iso/*.iso "/home/user/radiantwave/${FILENAME}" echo "KIOSK_ISO_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - # - name: Move old kiosk images to archive - # run: | - # ssh nclack@157.245.124.103 'mv /mnt/infrastructure_data/files/images/radiantwave-home* /mnt/infrastructure_data/files/images/archives/' - - name: Copy kiosk ISO to fileserver run: | scp "/home/user/radiantwave/${KIOSK_ISO_FILENAME}" \ diff --git a/.gitea/workflows/build-prod-rpi.yml b/.gitea/workflows/build-prod-rpi.yml index bb267ce..609ac5e 100644 --- a/.gitea/workflows/build-prod-rpi.yml +++ b/.gitea/workflows/build-prod-rpi.yml @@ -30,9 +30,6 @@ jobs: cp -L result/sd-image/*.img.zst "/home/user/radiantwave/${FILENAME}" echo "RPI_IMAGE_FILENAME=${FILENAME}" >> "$GITHUB_ENV" - - name: Move old Raspberry Pi images to archive - run: tools/archive-images.sh nclack@157.245.124.103 /mnt/infrastructure_data/files/images /mnt/infrastructure_data/files/images/archives 'radiantwave-rpi*' - - name: Copy Raspberry Pi image to fileserver run: | scp "/home/user/radiantwave/${RPI_IMAGE_FILENAME}" \ diff --git a/AGENTS.md b/AGENTS.md index b4b741f..589965f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,12 @@ Hosted on Gitea: `https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig` ## Prod/dev split -Single branch (`main`) for this repo. The production vs. dev split is at the **flake input + upgrade ref** level: +Two branches — `main` (prod) and `dev`. There are **no `-dev` configs**: each hardware target has exactly one config, and the branch selects its channel via the single `channel` block at the top of `flake.nix`: -- Production variants consume the `radiantwave` input (`?ref=main`) and auto-upgrade from `KioskConfig.git#` on `main`. -- `-dev` variants consume the `radiantwave-dev` input (`?ref=dev`) and auto-upgrade from `KioskConfig.git?ref=dev#-dev`. +- `main`: `channel.appInput = radiantwave` (main), `atticRepo = "radiantwave"`, `gitRef = ""` (default branch). +- `dev`: `channel.appInput = radiantwave-dev`, `atticRepo = "radiantwave-dev"`, `gitRef = "?ref=dev"`. + +Everything downstream (the configs, ISOs, updater module, autoUpgrade refs) reads from `channel`, so a `dev`→`main` merge carries your changes automatically — no copying between sibling configs. To flip a channel, change only those three `channel` values. When merging, reconcile just this one block. Both gitea repos are public: all git access is plain HTTPS (`git+https://...`), no SSH keys or access tokens on the kiosks. Don't reintroduce `git+ssh` URLs in flake/upgrade refs. @@ -36,16 +38,16 @@ nix eval .#nixosConfigurations --apply builtins.attrNames ## Config variants +One config per hardware target. Dev/prod is the branch, not the config — see `channel` in `flake.nix`: + | Name | Target hardware | Disk device | App input | |---|---|---|---| | `radiantwavekiosk` | Intel NUC 14 (NVMe) | `/dev/nvme0n1` | `radiantwave` (main) | -| `radiantwavekiosk-dev` | Intel NUC 14 (NVMe) | `/dev/nvme0n1` | `radiantwave-dev` (dev) | | `radiantwavekiosk-commercial` | Commercial HW (SATA) | `/dev/sda` | `radiantwave` (main) | -| `radiantwavekiosk-commercial-dev` | Commercial HW (SATA) | `/dev/sda` | `radiantwave-dev` (dev) | | `radiantwavekiosk-android` | Intel NUC 14, rotated display | `/dev/nvme0n1` | `radiantwave` (main) | -| `radiantwavekiosk-android-dev` | Intel NUC 14, rotated display | `/dev/nvme0n1` | `radiantwave-dev` (dev) | | `radiantwavekiosk-rpi` | Raspberry Pi 5 (SD image) | — | `radiantwave` (main) | -| `radiantwavekiosk-rpi-dev` | Raspberry Pi 5 (SD image) | — | `radiantwave-dev` (dev) | + +On the `dev` branch, the same four configs use `radiantwave-dev` and track `?ref=dev`. Every variant sets its own `networking.hostName` (same as the config name) and `system.configurationRevision`, so `tools/find_systems.zsh` can identify fleet members. @@ -56,11 +58,10 @@ Each ISO installs exactly one closure using that closure's disk layout (`mkIso` | ISO | Installs | Disk layout | |---|---|---| | `kiosk-iso` | `radiantwavekiosk` | NVMe (`disk-config.nix`) | -| `dev-iso` | `radiantwavekiosk-dev` | NVMe | | `commercial-iso` | `radiantwavekiosk-commercial` | SATA (`commercial-disk-config.nix`) | -| `commercial-dev-iso` | `radiantwavekiosk-commercial-dev` | SATA | | `android-iso` | `radiantwavekiosk-android` | NVMe | -| `android-dev-iso` | `radiantwavekiosk-android-dev` | NVMe | + +These same names build **dev-flavored** images when the flake is checked out on the `dev` branch (the channel block selects the dev app, attic repo, and upgrade ref), so there are no separate `-dev` ISOs. The installer lives once in `zero-touch-installer.nix` and is parameterized by `targetConfig`. Adding a new variant = add the `nixosConfigurations.*` entry, a matching `packages.x86_64-linux.*` line, and an ISO if needed. @@ -91,18 +92,19 @@ Regenerate when major hardware changes, not on a schedule — reports are snapsh - **Disk device differs by variant**: `disk-config.nix` uses `/dev/nvme0n1`, `commercial-disk-config.nix` uses `/dev/sda`. `mkIso` pairs each ISO with the correct layout automatically — keep new ISOs paired correctly. - **Changing a config name** requires updating that config's `upgradeFlakeRef` in `flake.nix` (it embeds the name). +- **The `channel` block is the dev/prod toggle** in `flake.nix` — `appInput`, `atticRepo`, `gitRef`. When merging `dev`→`main`, keep this block's values for the target branch; only this block differs between branches. - **`rotated_display.nix` uses `mkForce`** on `loginShellInit` — it replaces the entire kiosk startup script from `configuration.nix`; don't edit both. - **Auto-update**: `system.autoUpgrade` runs every 10 min (`*:00/10`) with `--refresh`, operation `switch`, no auto-reboot. A `radiantwave-backup` rsync runs before each attempt. The health-check/rollback service is written but commented out in `configuration.nix`. - **No binary cache**: kiosks fetch from cache.nixos.org only and compile RadiantWave itself from source during upgrades. Attic integration was planned (see `PLAN-update-system.md`) but never wired; the old netrc/JWT setup has been removed. - **Secrets**: none in-tree anymore (gitea repos are public). If either repo goes private, re-add auth via `nix.settings.access-tokens` in `configuration.nix`. -- **Flake inputs**: `radiantwave` tracks main, `radiantwave-dev` tracks `?ref=dev`; prod and `-dev` configs consume them respectively. Updating them independently is intentional. +- **Flake inputs**: `radiantwave` tracks main, `radiantwave-dev` tracks `?ref=dev`. The `channel` block picks which one the configs consume. Updating them independently is intentional. - **SSH keys on machines are inbound-only** (`authorizedKeys` for admin access); outbound git is HTTPS. The root key in `zero-touch-installer.nix` must stay in sync with `myKey` in `configuration.nix`. ## File map -- `flake.nix` — entry point: inputs, builders (`mkKioskSystem`, `mkRpiKioskSystem`, `mkIso`), all system definitions, facter report wiring +- `flake.nix` — entry point: inputs, the `channel` dev/prod selector, builders (`mkKioskSystem`, `mkRpiKioskSystem`, `mkIso`), all system definitions, facter report wiring - `configuration.nix` — shared NixOS config (packages, services, kiosk user, auto-upgrade, health check, VM variant) -- `zero-touch-installer.nix` — parameterized USB installer module used by all six ISOs +- `zero-touch-installer.nix` — parameterized USB installer module used by all three ISOs - `disk-config.nix` — NVMe disk layout (NUC) - `commercial-disk-config.nix` — SATA disk layout (commercial HW) - `rotated_display.nix` — portrait display override for Android variant diff --git a/UPDATES.md b/UPDATES.md new file mode 100644 index 0000000..4dae974 --- /dev/null +++ b/UPDATES.md @@ -0,0 +1,19 @@ +Implementation complete and verified. Here's what was done: +Implemented (both repos on dev) +Checkpoints (return points if something fails): +- radiantwave → tag pre-live-update @ d84307b +- KioskConfig → tag pre-live-update @ b8d0f59 +radiantwave — .gitea/workflows/dev.yml: Added a "Publish manifest" step after the Attic push. It writes {"version","storePath"} using readlink -f ./result-pkg (the exact closure pushed to the dev Attic), then scps it to radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json. main.yml untouched. +KioskConfig: +- New radiantwave-updater.nix module: tmpfiles seed (L /var/lib/radiantwave/current → ), a systemd oneshot service (curl manifest → nix copy --from dev Attic, never builds → atomic mv -T swap), and a 2-min persistent timer. +- flake.nix: imports the module in both builders and enables it on all 4 -dev variants (radiantwavekiosk-dev, -commercial-dev, -android-dev, -rpi-dev) pointing at the dev manifest/attic. +- configuration.nix: launches via /var/lib/radiantwave/current/bin/radiantwave when the updater is enabled; prod keeps /run/current-system/sw/bin/radiantwave. +Verified +- All dev configs evaluate with enable=true; prod enable=false, no updater service/timer, login path unchanged. +- Rendered service script correct (escaped shell vars, resoled Nix ínterps); dev login line + tmpfiles seed rule confirmed. +- All 14 nixosConfigurations evaluate; toplevel dry-run of radiantwavekiosk-dev plans cleanly. +- dev.yml parses as valid YAML with the new step. +Before this actually works end-to-end, you need to +1. Create /mnt/infrastructure_data/files/radiantwave-dev/ on the infra host and confirm it's served as https://files.radiantwavetech.com/radiantwave-dev/manifest.json. +2. Commit + push these changes (I staged radiantwave-updater.nix so nix can see it; nothing is committed yet) — push dev.yml change to radiantwave dev, and the KioskConfig changes to dev. The dev-iso/etc. workflows will then bake the seed in. +Note: git add radiantwave-updater.nix was needed for flake evaluation; the KioskConfig changes are staged but uncommitted. Want me to commit them? \ No newline at end of file diff --git a/android-kiosk-edid.bin b/android-kiosk-edid.bin new file mode 100644 index 0000000..3c37dfb Binary files /dev/null and b/android-kiosk-edid.bin differ diff --git a/configuration.nix b/configuration.nix index e51b742..6838a40 100644 --- a/configuration.nix +++ b/configuration.nix @@ -1,7 +1,15 @@ -{ pkgs, radiantwave, upgradeFlakeRef, radiantwavePlymouth, ... }: +{ config, pkgs, radiantwave, upgradeFlakeRef, radiantwavePlymouth, ... }: let radiantApp = radiantwave.packages.${pkgs.system}.default; + # When the live-updater module is enabled, RadiantWave launches through the + # mutable /var/lib/radiantwave/current + # pointer so it can be swapped without a full nixos-rebuild. Otherwise it + # launches straight from the immutable system closure as before. + launchBin = + if config.services.radiantwaveUpdater.enable or false + then "/var/lib/radiantwave/current/bin/radiantwave" + else "/run/current-system/sw/bin/radiantwave"; myKey = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4qWNZnYU5JaqR9c2qh6oFZktzVNBOx42PfDaGofCL6 cnclack@pm.me macbook" ]; in { @@ -159,7 +167,7 @@ in # Release the splash screen right before the compositor takes over the display sudo -n plymouth quit --wait || true - exec dbus-run-session cage -- /run/current-system/sw/bin/radiantwave -d -s > /tmp/kiosk.log 2>&1 + exec dbus-run-session cage -- ${launchBin} -d -s > /tmp/kiosk.log 2>&1 fi ''; @@ -255,32 +263,4 @@ in echo "Backup completed at $(date)" > "$BACKUP_DIR/.backup-timestamp" ''; }; - - # --- Health check service: runs after nixos-upgrade --- - # systemd.services.radiantwave-healthcheck = { - # description = "Verify RadiantWave is running after upgrade, rollback if not"; - # after = [ "nixos-upgrade.service" ]; - # wantedBy = [ "nixos-upgrade.service" ]; - # serviceConfig = { - # Type = "oneshot"; - # # Give cage time to start radiantwave after the switch - # ExecStartPre = "${pkgs.coreutils}/bin/sleep 90"; - # }; - # script = '' - # # The installed `radiantwave` is a makeWrapper script that execs the real - # # binary (`.radiantwave-unwrapped`), so the process comm is not exactly - # # `radiantwave`; match the exec'd binary's command line instead. - # if ${pkgs.procps}/bin/pgrep -f '\.radiantwave-unwrapped' > /dev/null; then - # echo "RadiantWave is running, upgrade successful" - # exit 0 - # fi - - # echo "RadiantWave not found after upgrade, rolling back..." - - # # Roll back to previous NixOS generation - # /run/current-system/sw/bin/nixos-rebuild switch --rollback - - # echo "Rollback completed at $(date)" - # ''; - # }; } diff --git a/edid-override.nix b/edid-override.nix new file mode 100644 index 0000000..10be3e6 --- /dev/null +++ b/edid-override.nix @@ -0,0 +1,74 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.kioskEdid; + + overrideModule = types.submodule { + options = { + connector = mkOption { + type = types.str; + example = "HDMI-A-1"; + description = '' + DRM connector name to apply the EDID override to. + Check `ls /sys/class/drm/` on target hardware to confirm the + exact name (e.g. HDMI-A-1, HDMI-A-2). + ''; + }; + + edidFile = mkOption { + type = types.path; + description = '' + Path to the binary EDID file to force for this connector. + ''; + }; + + forceConnected = mkOption { + type = types.bool; + default = true; + description = '' + Also force this connector to be treated as connected + (video=: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.lock b/flake.lock index 8532e96..8cbaeca 100644 --- a/flake.lock +++ b/flake.lock @@ -240,11 +240,11 @@ "nixpkgs": "nixpkgs_4" }, "locked": { - "lastModified": 1787673353, - "narHash": "sha256-aLWuba8+AVfYQc3GNBSb74d/p7F28K8yI2MUMZKq89k=", + "lastModified": 1787943320, + "narHash": "sha256-zoHqtwO0DV3z/N311IxiMLrbyQHrw7HIoL1FQW5pGDk=", "ref": "dev", - "rev": "400c6a782be0cda69fc41b47783693536e7265a3", - "revCount": 45, + "rev": "2917d33866f052eca1e9daea175d95382a9087f2", + "revCount": 50, "type": "git", "url": "https://gitea.radiantwavetech.com/RadiantWaveTech/radiantwave.git" }, diff --git a/flake.nix b/flake.nix index 8d082ab..cbddecc 100644 --- a/flake.nix +++ b/flake.nix @@ -55,6 +55,38 @@ revision = self.rev or self.dirtyRev or null; + # DEV/PROD CHANNEL SELECTOR — the single block that differs between the + # `main` and `dev` branches. Everything downstream (the four configs, the + # ISOs, the updater module) is identical on both branches and pulls from + # this channel, so a `dev`-to-`main` merge carries your changes with it + # automatically. There are no more `-dev` sibling configs to copy. + # + # main: appInput = radiantwave (main), atticRepo = "radiantwave", + # gitRef = "" (default branch) + # dev: appInput = radiantwave-dev, atticRepo = "radiantwave-dev", + # gitRef = "?ref=dev" + # + # To flip branches, change ONLY these three values (then push/merge). + channel = { + appInput = radiantwave-dev; # dev + atticRepo = "radiantwave-dev"; # dev + gitRef = "?ref=dev"; # dev + }; + + # Live app-update side channel. The manifest is served by the + # fileserver; closures are fetched (never built) from the channel's + # Attic cache. `repo` selects the channel — "radiantwave" (main) or + # "radiantwave-dev" (dev). All variants follow this same update + # procedure; only the channel differs. + radiantwaveUpdaterModule = { repo, baseline }: { + services.radiantwaveUpdater = { + enable = true; + baselinePackage = baseline; + manifestUrl = "https://files.radiantwavetech.com/${repo}/manifest.json"; + atticUrl = "https://attic.radiantwavetech.com/${repo}"; + }; + }; + mkKioskSystem = { system, radiantwave, @@ -79,6 +111,7 @@ system.configurationRevision = revision; } ./configuration.nix + ./radiantwave-updater.nix disko.nixosModules.disko diskModule nixos-facter-modules.nixosModules.facter @@ -116,6 +149,7 @@ ]; }) ./configuration.nix + ./radiantwave-updater.nix radiantwave.nixosModules.default { networking.hostName = hostName; @@ -167,81 +201,69 @@ in { packages.x86_64-linux.radiantwavekiosk = self.nixosConfigurations.radiantwavekiosk.config.system.build.toplevel; - packages.x86_64-linux.radiantwavekiosk-dev = self.nixosConfigurations.radiantwavekiosk-dev.config.system.build.toplevel; packages.x86_64-linux.radiantwavekiosk-commercial = self.nixosConfigurations.radiantwavekiosk-commercial.config.system.build.toplevel; - packages.x86_64-linux.radiantwavekiosk-commercial-dev = self.nixosConfigurations.radiantwavekiosk-commercial-dev.config.system.build.toplevel; packages.x86_64-linux.radiantwavekiosk-android = self.nixosConfigurations.radiantwavekiosk-android.config.system.build.toplevel; - packages.x86_64-linux.radiantwavekiosk-android-dev = self.nixosConfigurations.radiantwavekiosk-android-dev.config.system.build.toplevel; packages.x86_64-linux.radiantwavekiosk-rpi = self.nixosConfigurations.radiantwavekiosk-rpi.config.system.build.toplevel; - packages.x86_64-linux.radiantwavekiosk-rpi-dev = self.nixosConfigurations.radiantwavekiosk-rpi-dev.config.system.build.toplevel; - # Kiosk systems. + # Kiosk systems — one per hardware target, no dev/prod siblings. # - # Production variants consume the `radiantwave` input (?ref=main); - # `-dev` variants consume `radiantwave-dev` (?ref=dev) and track the - # dev branch of this repo via their autoUpgrade flake refs. + # Every config consumes the channel selected at the top of this file + # (see `channel`): the RadiantWave app input, the Attic repo/manifest + # it fetches live updates from, and the KioskConfig git ref it + # auto-upgrades from. On `main` the channel is prod; on `dev` it is the + # dev channel. Because there is exactly one config per hardware type, + # changes made on `dev` reach prod via a plain merge — no copying. nixosConfigurations = { radiantwavekiosk = mkKioskSystem { system = "x86_64-linux"; - radiantwave = radiantwave; + radiantwave = channel.appInput; hostName = "radiantwavekiosk"; diskModule = ./disk-config.nix; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git#radiantwavekiosk"; - }; - radiantwavekiosk-dev = mkKioskSystem { - system = "x86_64-linux"; - radiantwave = radiantwave-dev; - hostName = "radiantwavekiosk-dev"; - diskModule = ./disk-config.nix; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git?ref=dev#radiantwavekiosk-dev"; + upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk"; + extraModules = [ + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.x86_64-linux.default; }) + ]; }; radiantwavekiosk-commercial = mkKioskSystem { system = "x86_64-linux"; - radiantwave = radiantwave; + radiantwave = channel.appInput; hostName = "radiantwavekiosk-commercial"; diskModule = ./commercial-disk-config.nix; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git#radiantwavekiosk-commercial"; - }; - radiantwavekiosk-commercial-dev = mkKioskSystem { - system = "x86_64-linux"; - radiantwave = radiantwave-dev; - hostName = "radiantwavekiosk-commercial-dev"; - diskModule = ./commercial-disk-config.nix; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git?ref=dev#radiantwavekiosk-commercial-dev"; + upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-commercial"; + extraModules = [ + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.x86_64-linux.default; }) + ]; }; # Rotated screen for android-style displays. radiantwavekiosk-android = mkKioskSystem { system = "x86_64-linux"; - radiantwave = radiantwave; + radiantwave = channel.appInput; hostName = "radiantwavekiosk-android"; diskModule = ./disk-config.nix; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git#radiantwavekiosk-android"; - extraModules = [./rotated_display.nix]; - }; - radiantwavekiosk-android-dev = mkKioskSystem { - system = "x86_64-linux"; - radiantwave = radiantwave-dev; - 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]; + upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-android"; + extraModules = [ + ./rotated_display.nix + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.x86_64-linux.default; }) + ]; }; # Raspberry Pi 5. radiantwavekiosk-rpi = mkRpiKioskSystem { - radiantwave = radiantwave; + radiantwave = channel.appInput; hostName = "radiantwavekiosk-rpi"; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git#radiantwavekiosk-rpi"; - }; - radiantwavekiosk-rpi-dev = mkRpiKioskSystem { - radiantwave = radiantwave-dev; - hostName = "radiantwavekiosk-rpi-dev"; - upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git?ref=dev#radiantwavekiosk-rpi-dev"; + upgradeFlakeRef = "git+https://gitea.radiantwavetech.com/RadiantWaveTech/KioskConfig.git${channel.gitRef}#radiantwavekiosk-rpi"; + extraModules = [ + (radiantwaveUpdaterModule { repo = channel.atticRepo; baseline = channel.appInput.packages.aarch64-linux.default; }) + ]; }; # === USB Images === # Each ISO installs exactly the named system closure using that # system's disk layout. NVMe layouts pair with ./disk-config.nix, # SATA with ./commercial-disk-config.nix — don't cross them. + # The same names build dev-flavored images when the flake is checked + # out on the `dev` branch (the channel block above selects the dev + # app, attic repo, and upgrade ref), so there are no separate -dev + # ISOs. kiosk-iso = mkIso { targetConfig = "radiantwavekiosk"; diskModule = ./disk-config.nix; @@ -254,18 +276,6 @@ targetConfig = "radiantwavekiosk-android"; diskModule = ./disk-config.nix; }; - dev-iso = mkIso { - targetConfig = "radiantwavekiosk-dev"; - diskModule = ./disk-config.nix; - }; - commercial-dev-iso = mkIso { - targetConfig = "radiantwavekiosk-commercial-dev"; - diskModule = ./commercial-disk-config.nix; - }; - android-dev-iso = mkIso { - targetConfig = "radiantwavekiosk-android-dev"; - diskModule = ./disk-config.nix; - }; }; }; } 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.") diff --git a/radiantwave-updater.nix b/radiantwave-updater.nix new file mode 100644 index 0000000..a020efb --- /dev/null +++ b/radiantwave-updater.nix @@ -0,0 +1,98 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.radiantwaveUpdater; +in +{ + options.services.radiantwaveUpdater = { + enable = lib.mkEnableOption "live RadiantWave app updates via a polled manifest"; + + manifestUrl = lib.mkOption { + type = lib.types.str; + description = "HTTP(S) URL of the JSON manifest announcing the current app store path."; + }; + + atticUrl = lib.mkOption { + type = lib.types.str; + description = "Attic cache URL to fetch the app closure from (fetch-only, never builds)."; + }; + + baselinePackage = lib.mkOption { + type = lib.types.package; + description = "Copy of RadiantWave baked into the system closure, used to seed the runtime pointer before the first update."; + }; + }; + + config = lib.mkIf cfg.enable { + # Runtime pointer the login shell launches RadiantWave from. Seeded on + # first boot to the baseline copy already baked into the system closure + # (so the ISO carries it with no duplication); the updater service later + # re-points it atomically to newer closures fetched from Attic. + systemd.tmpfiles.rules = [ + "L /var/lib/radiantwave/current - - - - ${cfg.baselinePackage.outPath}" + ]; + + systemd.services.radiantwave-updater = { + description = "Poll the RadiantWave manifest and swap the live app pointer"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + # NOTE: no RemainAfterExit here. Keeping the oneshot "active" after + # it finishes breaks the timer's OnUnitActiveSec re-arming, so the + # 2-min cadence stops after the first run (NEXT goes empty, Trigger + # becomes n/a). Let the service return to dead so the timer refires. + }; + script = '' + set -u + + NEW_PATH=$(${pkgs.curl}/bin/curl -fsSL "${cfg.manifestUrl}" \ + | ${pkgs.jq}/bin/jq -er .storePath) || { + echo "radiantwave-updater: failed to fetch/parse manifest from ${cfg.manifestUrl}" + exit 0 + } + + CURRENT=$(${pkgs.coreutils}/bin/readlink /var/lib/radiantwave/current 2>/dev/null || true) + + if [ -z "$NEW_PATH" ] || [ "$NEW_PATH" = "$CURRENT" ]; then + # Already current; make sure the running closure is GC-rooted. + ${pkgs.coreutils}/bin/ln -sfn "$CURRENT" /var/lib/radiantwave/.current-root 2>/dev/null || true + echo "radiantwave-updater: already current (''${CURRENT})" + exit 0 + fi + + # Realize the closure via the variant's Attic cache plus cache.nixos.org. + # nixpkgs deps (e.g. sqlite) are signed by cache.nixos.org and Attic + # intentionally skips them to avoid re-uploading the world, so fetch + # them from cache.nixos.org and the whole closure resolves without ever + # building from source. Trusted public keys come from the system config + # (both Attic repos are trusted in configuration.nix). --add-root pins + # the fetched closure so GC can't collect it while the swap below makes + # it live. + echo "radiantwave-updater: acquiring ''${NEW_PATH}" + ${pkgs.nix}/bin/nix-store -r "$NEW_PATH" \ + --add-root /var/lib/radiantwave/.current-root \ + --option extra-substituters "${cfg.atticUrl} https://cache.nixos.org" || { + echo "radiantwave-updater: nix copy failed for ''${NEW_PATH}" + exit 1 + } + + # Atomic swap: the app never observes a half-updated pointer. + ${pkgs.coreutils}/bin/ln -sfn "$NEW_PATH" /var/lib/radiantwave/current.tmp + ${pkgs.coreutils}/bin/mv -T /var/lib/radiantwave/current.tmp /var/lib/radiantwave/current + echo "radiantwave-updater: swapped ''${CURRENT} -> ''${NEW_PATH}" + ''; + }; + + systemd.timers.radiantwave-updater = { + description = "Check for RadiantWave app updates"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = "2min"; + Persistent = true; + Unit = "radiantwave-updater.service"; + }; + }; + }; +} diff --git a/temp/computer-direct_to_kiosk.bin b/temp/computer-direct_to_kiosk.bin new file mode 100644 index 0000000..3c37dfb Binary files /dev/null and b/temp/computer-direct_to_kiosk.bin differ diff --git a/temp/root/computer-direct_to_kiosk.bin b/temp/root/computer-direct_to_kiosk.bin new file mode 100644 index 0000000..3c37dfb Binary files /dev/null and b/temp/root/computer-direct_to_kiosk.bin differ diff --git a/temp/root/computer-splitter-out1_hdmi_to_kiosk-out2_wireless__unstable.bin b/temp/root/computer-splitter-out1_hdmi_to_kiosk-out2_wireless__unstable.bin new file mode 100644 index 0000000..e69de29 diff --git a/temp/root/computer-splitter-out1_wireless-out2_hdmi_to_kiosk__unstable.bin b/temp/root/computer-splitter-out1_wireless-out2_hdmi_to_kiosk__unstable.bin new file mode 100644 index 0000000..1ca080f Binary files /dev/null and b/temp/root/computer-splitter-out1_wireless-out2_hdmi_to_kiosk__unstable.bin differ diff --git a/temp/root/computer-splitter_out_1-direct_to_kiosk.bin b/temp/root/computer-splitter_out_1-direct_to_kiosk.bin new file mode 100644 index 0000000..a9d79a9 Binary files /dev/null and b/temp/root/computer-splitter_out_1-direct_to_kiosk.bin differ diff --git a/temp/root/computer-splitter_out_1-wireless-kiosk.bin b/temp/root/computer-splitter_out_1-wireless-kiosk.bin new file mode 100644 index 0000000..f9620e2 Binary files /dev/null and b/temp/root/computer-splitter_out_1-wireless-kiosk.bin differ diff --git a/temp/root/computer-splitter_out_2-direct_to_kiosk.bin b/temp/root/computer-splitter_out_2-direct_to_kiosk.bin new file mode 100644 index 0000000..a9d79a9 Binary files /dev/null and b/temp/root/computer-splitter_out_2-direct_to_kiosk.bin differ diff --git a/temp/root/computer-splitter_out_2-wireless-kiosk.bin b/temp/root/computer-splitter_out_2-wireless-kiosk.bin new file mode 100644 index 0000000..f9620e2 Binary files /dev/null and b/temp/root/computer-splitter_out_2-wireless-kiosk.bin differ diff --git a/temp/root/computer-wireless.bin b/temp/root/computer-wireless.bin new file mode 100644 index 0000000..e69de29 diff --git a/tools/find_systems.zsh b/tools/find_systems.zsh index bc8fc9e..7e47578 100755 --- a/tools/find_systems.zsh +++ b/tools/find_systems.zsh @@ -9,7 +9,7 @@ set -uo pipefail # no -e: we want to keep going past unreachable hosts -SUBNET="${1:-192.168.4.0/24}" +SUBNET="${1:-192.168.1.0/24}" SSH_USER="kiosk" SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new)