Merge pull request 'Dev' (#8) from dev into main
build-prod-android.yml / build-android (push) Successful in 4m27s
build-prod-commercial.yml / build-commercial (push) Successful in 4m39s
build-prod-kiosk.yml / build-kiosk (push) Successful in 4m32s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-29 02:01:44 +00:00
24 changed files with 479 additions and 143 deletions
+64 -24
View File
@@ -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}"
-4
View File
@@ -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}" \
@@ -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}" \
-4
View File
@@ -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}" \
-3
View File
@@ -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}" \
+15 -13
View File
@@ -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#<config>` on `main`.
- `-dev` variants consume the `radiantwave-dev` input (`?ref=dev`) and auto-upgrade from `KioskConfig.git?ref=dev#<config>-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
+19
View File
@@ -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 → <baked-in baseline>), 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?
Binary file not shown.
+10 -30
View File
@@ -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)"
# '';
# };
}
+74
View File
@@ -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=<connector>: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);
};
}
Generated
+4 -4
View File
@@ -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"
},
+66 -56
View File
@@ -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;
};
};
};
}
+128
View File
@@ -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.")
+98
View File
@@ -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";
};
};
};
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
+1 -1
View File
@@ -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)