Merge pull request #5 from bfe-noah/qemu-device-sim

qemu/: QEMU device simulator — boot the real kernel + userspace off-hardware
This commit is contained in:
2026-08-30 09:29:50 -06:00
committed by GitHub
70 changed files with 2604 additions and 449 deletions
+87 -18
View File
@@ -2,8 +2,9 @@
#
# Policy (mirrors flare-edge): only GitHub-owned actions get the repo token; the one
# third-party helper (taiki-e/install-action) is pinned and never handed a token.
# Host-testable jobs run on GitHub-hosted runners; only the heavy kernel build uses
# the self-hosted [self-hosted, warden-sdk] runner on bfe-mpc-0640 (added in P5).
# Every job runs on GitHub-hosted runners — no self-hosted runner may be reachable
# from this repo's workflows (ADR-0007: public-repo fork PRs would otherwise be
# able to run code on private infrastructure). kernel-build is dispatch-only.
name: ci
on:
@@ -24,6 +25,7 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 25
outputs:
passed: ${{ steps.result.outputs.passed }}
coverage: ${{ steps.result.outputs.coverage }}
@@ -33,7 +35,7 @@ jobs:
run: |
set -o pipefail
: > /tmp/test.log
for d in sim tools/config-lint; do
for d in sim tools/config-lint qemu/rs485-bridge; do
echo "== cargo test in $d ==" | tee -a /tmp/test.log
( cd "$d" && cargo test --locked ) 2>&1 | tee -a /tmp/test.log
done
@@ -57,6 +59,7 @@ jobs:
mcdc:
# 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: install gcc-14
@@ -77,6 +80,7 @@ jobs:
# Smoke-run the sim micro-benchmarks and emit the ns/op trend JSON. Regression
# gating against stored history is future work (no flare-edge pattern to copy).
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: run sim benchmarks
@@ -85,10 +89,42 @@ jobs:
cargo bench --locked --bench sim_bench 1> bench.txt 2> bench.json
echo "== timings =="; cat bench.txt
echo "== trend json =="; grep '"bench"' bench.json
- name: run rs485-bridge benchmarks
working-directory: qemu/rs485-bridge
run: |
cargo bench --locked --bench bridge_bench 1> bench.txt 2> bench.json
echo "== timings =="; cat bench.txt
echo "== trend json =="; grep '"bench"' bench.json
qemu-tools:
# The qemu/ device-sim build tooling must stay healthy on a plain hosted
# runner: shellcheck the scripts, build the initramfs (pinned busybox,
# fail-closed sha), and build the A/B disk image (unprivileged mkfs -d).
# Booting needs a zImage and therefore lives in kernel-build's smoke step.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: shellcheck qemu scripts
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
shellcheck -x qemu/*.sh qemu/tests/*.sh build/*.sh \
qemu/rootfs/etc/warden-lib.sh qemu/rootfs/etc/rc \
qemu/rootfs/sbin/init qemu/rootfs/init
- name: cache pinned busybox
uses: actions/cache@v4
with:
path: qemu/out/busybox-armv7l
key: busybox-armv7l-${{ hashFiles('qemu/busybox.sha256') }}
- name: build initramfs
run: bash qemu/mkinitramfs.sh
- name: build A/B disk image
run: bash qemu/mkimage.sh
patches-apply:
# The RV1106 series must apply cleanly onto pristine linux-6.18.46.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: cache pristine kernel tarball
@@ -96,11 +132,8 @@ jobs:
with:
path: ~/linux-6.18.46.tar.xz
key: linux-6.18.46-tarball
- name: fetch + verify pristine
run: |
[ -f ~/linux-6.18.46.tar.xz ] || \
curl -fSL https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.46.tar.xz -o ~/linux-6.18.46.tar.xz
echo "$(cat build/linux-6.18.46.tar.xz.sha256) $HOME/linux-6.18.46.tar.xz" | sha256sum -c -
- name: fetch + verify pristine (shared fail-closed fetcher)
run: bash build/fetch-kernel-tarball.sh "$HOME/linux-6.18.46.tar.xz"
- name: apply the series in order
run: |
tar -C /tmp -xf ~/linux-6.18.46.tar.xz
@@ -119,6 +152,7 @@ jobs:
prune-artifacts:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
steps:
@@ -142,35 +176,69 @@ jobs:
exit 0
kernel-build:
# Full hermetic build on the warden-sdk self-hosted runner (bfe-mpc-0640,
# ADR-0004). Manual-dispatch by design — a full kernel build is too heavy to run
# on every push; trigger it via `gh workflow run ci.yml` / the Actions UI.
# Full hermetic build on a GitHub-hosted runner (ADR-0007; supersedes the
# self-hosted half of ADR-0004 — a self-hosted runner must never be reachable
# from a public repo's workflows). Manual-dispatch by design — a full kernel
# build is heavy; trigger via `gh workflow run ci.yml` / the Actions UI.
if: github.event_name == 'workflow_dispatch'
needs: [prune-artifacts]
runs-on: [self-hosted, warden-sdk]
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: install cross toolchain + kernel build deps + qemu
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq gcc-arm-linux-gnueabihf qemu-system-arm \
cpio bc bison flex libssl-dev ccache
- name: provision `python` (SDK quirk — build calls bare python)
run: |
mkdir -p "$RUNNER_TEMP/bin"
ln -sf "$(command -v python3)" "$RUNNER_TEMP/bin/python"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
- name: cache pristine kernel tarball
uses: actions/cache@v4
with:
path: ~/linux-6.18.46.tar.xz
key: linux-6.18.46-tarball
# Ephemeral runners rebuild the whole tree every dispatch (~9 min of
# compile); ccache recovers most of it for an unchanged/lightly-changed
# series. Keyed on the config + patches so a real change misses cleanly.
- name: cache ccache
uses: actions/cache@v4
with:
path: ~/.ccache
key: kbuild-ccache-${{ hashFiles('build/warden_defconfig', 'patches/*.patch') }}
restore-keys: kbuild-ccache-
- name: build zImage + rv1106-warden.dtb
env:
# WORK must be OUTSIDE the repo checkout: build-kernel.sh applies the patch
# series with `git apply`, which silently ignores out-of-subdir paths when
# run inside another git repo (issue #1). $RUNNER_TEMP is outside the checkout.
WORK: ${{ runner.temp }}/kbuild-out
JOBS: 4 # belt-and-braces bound in addition to the runner's cgroup cap
# The kernel is freestanding; use the stable generic arm cross toolchain
# (Debian gcc-arm-linux-gnueabihf on the runner) rather than depending on
# the ephemeral Luckfox SDK checkout path.
# The kernel is freestanding; the generic arm cross toolchain links it.
CROSS_COMPILE: arm-linux-gnueabihf-
run: bash build/build-kernel.sh
WARDEN_CCACHE: 1
CCACHE_DIR: /home/runner/.ccache
# KERNEL_TARBALL is exported from the SHELL so $HOME expands — a literal
# `~` in a YAML env: value is never tilde-expanded and broke every
# dispatch until caught in review.
run: |
export KERNEL_TARBALL="$HOME/linux-6.18.46.tar.xz"
bash build/build-kernel.sh
ccache -s | head -4
# Boot smoke under QEMU: the zImage this job just built must reach the
# initramfs sentinel on -M virt (verified 2026-08-29: the canonical
# config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm.
- name: boot smoke (qemu-system-arm -M virt)
run: |
bash qemu/mkinitramfs.sh
bash qemu/tests/boot-smoke.sh \
"$RUNNER_TEMP/kbuild-out/linux-6.18.46/arch/arm/boot/zImage"
# Best-effort: the build IS the gate. Uploading the zImage/dtb to GitHub
# artifact storage can fail on an account-wide storage-quota hit (recalculated
# every 6-12h) that has nothing to do with this build — don't red-X a good
# kernel build over it. The outputs also remain on the self-hosted runner host.
# kernel build over it.
- uses: actions/upload-artifact@v4
continue-on-error: true
with:
@@ -187,6 +255,7 @@ jobs:
needs: [test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
+9 -1
View File
@@ -6,6 +6,8 @@ target/
# build artifacts (firmware/kernel objects are rebuilt from source)
*.o
*.a
*.elf
*.map
# scratch / editor
*.swp
@@ -14,8 +16,14 @@ target/
__pycache__/
*.pyc
# local tooling state (code-review harness cross-session memory, etc.)
# local tooling state
.claude/
# driver MC/DC harness build dirs
**/test/build/
# qemu device sim: build outputs (initramfs, disk images, cached busybox) and
# payload binaries (dropped in from flare-edge builds, never committed)
qemu/out/
qemu/payload/*
!qemu/payload/README.md
+335 -7
View File
@@ -1,10 +1,338 @@
warden-sdk is dual-licensed under either of
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
* Apache License, Version 2.0 (LICENSE-APACHE)
* MIT license (LICENSE-MIT)
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
at your option.
Preamble
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-39
View File
@@ -1,39 +0,0 @@
MIT License
Copyright (c) 2026 BlueFlare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-------------------------------------------------------------------------------
Third-party components carry their own licenses, which are not superseded by
the above:
- LVGL (the UI library, fetched during build, not vendored here) is MIT
licensed by its authors.
- The Luckfox / Rockchip RV1106 SDK and its cross-toolchain are covered by
their respective vendor licenses.
- vendor/asic-rs is 256 Foundation's asic-rs, vendored into this repository
with git subtree and licensed under Apache-2.0. Its license text is kept
at vendor/asic-rs/LICENSE.txt. Files modified locally must carry a notice
saying so, per Apache-2.0 section 4(b); see vendor/README.md.
The contents of branding/ (BlueFlare logos, wordmarks, and splash artwork) are
NOT covered by the MIT license above. They are proprietary to BlueFlare, all
rights reserved, and are included only so the firmware builds and displays as
shipped. Do not reuse them in derivative works.
+22 -7
View File
@@ -12,12 +12,14 @@ the rest of the firmware: tested, benchmarked, reproducible, and honest about
what runs on real silicon versus what we simulate.
> Status: **bringup.** The hardware **simulator** and its tests, the RV1106 kernel
> forward-port as a reviewable `patches/` series, the hermetic kernel build, and two
> Tier-1 drivers at 100% MC/DC are all in and CI-green on `main`. What
> remains before this is on the production build path: installing the self-hosted
> kernel-build runner (see `docs/ci-cd.md`) and having flare-edge consume warden-sdk
> as a dependency — both [maintainer]-gated. Until then, flare-edge still builds firmware from
> the vendored SDK + `sdk-patches/`.
> forward-port as a reviewable `patches/` series, the hermetic kernel build, two
> Tier-1 drivers at 100% MC/DC, and the **QEMU device sim** (`qemu/`, ADR-0006:
> boots the real kernel + real userspace on `-M virt` — check-in/OTA against the
> mock portal, watchdog, RS485-to-sim bridge, 720x720 display + touch, all
> emulation-verified) are in. What remains before this is on the production build
> path: having flare-edge consume warden-sdk as a dependency (maintainer-gated).
> Until then, flare-edge still builds firmware from the vendored SDK +
> `sdk-patches/`.
## Why a new SDK
@@ -90,6 +92,8 @@ than duplicate each other.
```
sim/ the hardware simulator (Rust): membus/devmem, HPMCU, CRU, Modbus, RGA, NPU.
qemu/ the device simulator (ADR-0006): QEMU -M virt boots the real kernel and
real userspace; A/B disk layout, RS485 bridge into sim/, scenario tests.
drivers/ our own hardened drivers + their seams (relays, freshness; more migrate in).
patches/ the RV1106 kernel forward-port delta onto pristine linux-6.18.46 (subsystem-split).
kernel/ forward-port docs + provenance (rv1106-enablement/, PROVENANCE.md).
@@ -117,6 +121,17 @@ Evaluated against the stack philosophy — **openness, hardness, modernness**:
## Relationship to flare-edge
flare-edge (WardenOS: the LVGL UI + the `flared` daemon) is the product; warden-sdk
is what builds and tests it. During bootstrap, flare-edge consumes warden-sdk piece
is what builds and tests it. flare-edge is BlueFlare's private companion repo —
not publicly available — so flare-edge issue references and checkout paths in
this repo's docs are context, not reachable links. During bootstrap, flare-edge consumes warden-sdk piece
by piece: first the simulator (as a dev/test dependency), later the image build.
No flare-edge code moves here — only the SDK/build/sim/driver-seam layer.
## License
**GPL-2.0-only**, repo-wide (see `LICENSE`; a per-file SPDX identifier governs
where one is present, e.g. a few GPL-2.0-or-later kernel files). The kernel
material in `patches/` and `kernel/rv1106-enablement/` is derivative of the
Linux kernel and of GPL-2.0 vendor code either way — per-driver origin and
license are tracked in `kernel/rv1106-enablement/PROVENANCE.md`. Contributions
are accepted under the same license (inbound = outbound).
+62 -18
View File
@@ -10,8 +10,15 @@
# Env:
# KERNEL_TARBALL path to a local linux-6.18.46.tar.xz (skips the download)
# SDK_TC dir holding the arm-rockchip830 uclibc cross toolchain bin/
# CROSS_COMPILE cross-compiler prefix (default arm-rockchip830-linux-uclibcgnueabihf-;
# CI overrides with the generic arm-linux-gnueabihf-)
# WORK build scratch dir (default: a mktemp under $TMPDIR)
# JOBS parallel make jobs (default: nproc)
# WARDEN_KCONFIG_FRAGMENT
# optional kconfig fragment merged onto warden_defconfig
# (qemu/configs/virt.fragment builds the QEMU -M virt variant);
# every fragment option is verified to have taken effect
# WARDEN_CCACHE=1 compile through ccache (CI caches ~/.ccache)
#
# Requires: `python` (not python3) on PATH — the SDK quirk; the CI runner provides
# a project-local venv. Builds are SERIAL on the shared SDK box — never run two.
@@ -21,9 +28,8 @@ KVER=6.18.46
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/
REPO="$(cd "$HERE/.." && pwd)"
PATCHES="$REPO/patches"
SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256"
JOBS="${JOBS:-$(nproc)}"
URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz"
# The tarball URL + sha256 pin live in fetch-kernel-tarball.sh (shared with CI).
# A caller-provided WORK (e.g. CI's ${{ github.workspace }}/kbuild-out, from which
# artifacts are uploaded) is left intact; a scratch dir we mktemp'd here is our own
@@ -44,21 +50,9 @@ command -v python >/dev/null || { echo "need 'python' (not python3) on PATH —
# 1. obtain + verify the pristine tarball
mkdir -p "$WORK"
TB="${KERNEL_TARBALL:-$WORK/linux-$KVER.tar.xz}"
if [ ! -f "$TB" ]; then
log "downloading $URL"
curl -fSL "$URL" -o "$TB"
fi
# Fail closed: a missing pin (e.g. forgotten on a KVER bump) or a KERNEL_TARBALL
# pointed at an arbitrary file must refuse to build, never silently skip the check
# — the pristine tarball is the ONLY external input and integrity is the whole point.
[ -f "$SHA_FILE" ] || {
echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing to build from an unverified tarball" >&2
exit 1
}
want="$(cat "$SHA_FILE")"
got="$(sha256sum "$TB" | awk '{print $1}')"
[ "$want" = "$got" ] || { echo "tarball sha256 mismatch: want $want got $got" >&2; exit 1; }
log "tarball sha256 verified"
# Fetch + fail-closed sha256 verification live in ONE place shared with CI
# (a missing pin or a mismatch always refuses to build).
bash "$HERE/fetch-kernel-tarball.sh" "$TB"
# 2. extract pristine
SRC="$WORK/linux-$KVER"
@@ -97,6 +91,19 @@ log "patch series applied ($(basename "$SENTINEL") present)"
# 4. configure
log "configuring (warden_defconfig)"
cp "$HERE/warden_defconfig" "$SRC/.config"
# Optional kconfig fragment overlay (e.g. qemu/configs/virt.fragment for the
# QEMU -M virt device-sim variant). Fail closed if set but unreadable — never
# silently build the wrong kernel. Unset => the canonical RV1106 build,
# byte-identical to a build without this hook.
if [ -n "${WARDEN_KCONFIG_FRAGMENT:-}" ]; then
[ -f "$WARDEN_KCONFIG_FRAGMENT" ] || {
echo "FATAL: WARDEN_KCONFIG_FRAGMENT set but not a file: $WARDEN_KCONFIG_FRAGMENT" >&2
exit 1
}
FRAG="$(cd "$(dirname "$WARDEN_KCONFIG_FRAGMENT")" && pwd)/$(basename "$WARDEN_KCONFIG_FRAGMENT")"
log "merging kconfig fragment $(basename "$FRAG")"
( cd "$SRC" && ARCH=arm ./scripts/kconfig/merge_config.sh -m .config "$FRAG" )
fi
# CROSS_COMPILE defaults to the Luckfox SDK uclibc prefix (set SDK_TC to its bin/),
# but the kernel is freestanding, so a caller may override with a generic arm cross
# toolchain instead — e.g. CROSS_COMPILE=arm-linux-gnueabihf- (in Debian's
@@ -108,9 +115,46 @@ command -v "${CROSS_COMPILE}gcc" >/dev/null \
|| { echo "cross toolchain ${CROSS_COMPILE}gcc not on PATH (set SDK_TC, or CROSS_COMPILE to one that is)" >&2; exit 1; }
make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" olddefconfig >/dev/null
# Fragment took-effect assertion: merge_config -m only pastes text, and
# olddefconfig silently resolves any symbol whose dependencies are unmet —
# a fragment option could be dropped without a word. Verify every explicit
# request in the fragment survived into the final .config; fail loud if not.
if [ -n "${WARDEN_KCONFIG_FRAGMENT:-}" ]; then
frag_fail=0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
CONFIG_*=*)
grep -qxF "$line" "$SRC/.config" || {
echo "FATAL: fragment option '$line' did not take effect (unmet Kconfig dependency?)" >&2
frag_fail=1
} ;;
"# CONFIG_"*" is not set")
# A disable succeeded if the symbol is NOT set: Kconfig writes either
# the literal "is not set" line or (when dependencies gate the symbol
# out) nothing at all — both are valid outcomes. Only "still =value"
# is a failed disable. (A typo'd symbol disables nothing and is
# harmless by construction.)
opt="${line#\# }"; opt="${opt% is not set}"
grep -qE "^$opt=" "$SRC/.config" && {
echo "FATAL: fragment disabled '$opt' but it is still set in the final .config" >&2
frag_fail=1
} ;;
esac
done < "$FRAG"
[ "$frag_fail" = 0 ] || exit 1
log "fragment options verified in final .config"
fi
# Optional ccache (CI: cache ~/.ccache across dispatches; harmless if unset).
KCC="${CROSS_COMPILE}gcc"
if [ "${WARDEN_CCACHE:-0}" = 1 ]; then
command -v ccache >/dev/null || { echo "FATAL: WARDEN_CCACHE=1 but ccache not installed" >&2; exit 1; }
KCC="ccache ${CROSS_COMPILE}gcc"
fi
# 5. build zImage + the board dtb
log "building zImage + rv1106-warden.dtb (-j$JOBS)"
make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" -j"$JOBS" \
make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" \
zImage rockchip/rv1106-warden.dtb
Z="$SRC/arch/arm/boot/zImage"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Fetch (with retries) and sha256-verify the pristine kernel tarball into $1.
# Single source of truth for the URL + verification used by build-kernel.sh
# and both CI jobs (patches-apply, kernel-build) — a KVER bump edits this file
# and build-kernel.sh only. FAILS CLOSED: a missing pin refuses to proceed.
#
# Usage: fetch-kernel-tarball.sh <destination-path>
set -euo pipefail
KVER=6.18.46
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/
SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256"
URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz"
TB="${1:?usage: fetch-kernel-tarball.sh <destination-path>}"
# Pin first: a forgotten pin on a KVER bump should refuse BEFORE burning a
# 140MB download it will then reject anyway.
[ -f "$SHA_FILE" ] || {
echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing an unverified tarball" >&2
exit 1
}
if [ ! -f "$TB" ]; then
echo "== downloading $URL"
curl --retry 3 --retry-delay 5 --retry-connrefused -fSL "$URL" -o "$TB"
fi
want="$(cat "$SHA_FILE")"
got="$(sha256sum "$TB" | awk '{print $1}')"
[ "$want" = "$got" ] || { echo "FATAL: tarball sha256 mismatch: want $want got $got" >&2; exit 1; }
echo "== tarball sha256 verified: $TB"
+34 -5
View File
@@ -65,13 +65,15 @@ supervisor logic runs in CI with no panel.
the boot-mode register's survives-warm-reset / cleared-by-POR behaviour (the MaskRom
recovery maneuver). The matching firmware-side `Bus` seam on flared's `devmem` — so
the shipped ladder can be asserted to poke the confirmed offset, never the wrong-SoC
one — lands when flare-edge consumes warden-sdk (§7 item 3, [maintainer]-gated), not yet on
one — lands when flare-edge consumes warden-sdk (§8 item 3, maintainer-gated), not yet on
flare-edge `main`.
- **`modbus` — RS-485 device end.** Done. `ModbusSlave`: a byte-in/byte-out RTU slave
(CRC16 byte-identical to the master, FC 0x010x06/0x0F/0x10/0x11, exception replies,
and fault injection — silent-drop and forced-NAK) so `warden-modbus`'s master can be
hardened to MC/DC against realistic device behaviour with no serial hardware. MEI
(0x2B/0x0E) identification is the documented follow-up.
(0x2B/0x0E) identification is the documented follow-up. The same slave also serves
as the QEMU device sim's field bus: `qemu/rs485-bridge/` feeds it from a serial
chardev so the guest's real master polls it over what it believes is /dev/ttyS4 (§7).
- **`npu` — NPU load model.** Done. `NpuSim` models `/proc/rknpu/load` (the exact
"NPU load: N%" text the sysmon reads) behind the path seam, so the load-readout UI
is host-testable. NPU *compute* is explicitly out of scope — no inference runs here.
@@ -86,7 +88,7 @@ supervisor logic runs in CI with no panel.
Integration with flare-edge: flared implements `MemBus` for `/dev/mem` and gains
`#[cfg(test)]` tests driving its real arm/beat logic against `HpmcuSim`. This needs
warden-sdk reachable as a Cargo dependency in CI — i.e. a remote for this repo,
which is a **[maintainer]-go-ahead item** (credential/remote creation). Until then the
which is a **maintainer go-ahead item** (credential/remote creation). Until then the
firmware-side seam and a local test double land in flare-edge, unified with `sim/`
once the dependency exists. No duplication of *logic* — only the tiny trait.
@@ -152,14 +154,41 @@ kernel move as one matched boot+oem image, never a partial reflash.
series honest against pristine 6.18.46; provenance is in `patches/README.md` and
`kernel/rv1106-enablement/`.
## 7. Order of work
## 7. Device emulation (`qemu/`) — see ADR-0006
The third simulator, deliberately not named "sim": a QEMU VM (`-M virt,highmem=off`,
one Cortex-A7, 256M — the RV1106G3's shape) that boots the real forward-ported
kernel and real userspace, entering at `-kernel zImage` because everything below
(BootROM, idblock/DDR-init, U-Boot, the BCB A/B machinery) is closed blobs plus
mask ROM. The canonical RV1106 zImage boots virt unmodified; an additive kconfig
fragment (`qemu/configs/virt.fragment` via `WARDEN_KCONFIG_FRAGMENT`) adds the
scenario devices (PCI serial for RS485, i6300esb watchdog, WireGuard,
virtio-gpu/input for the 720x720 UI). The virtio disk carries the device's exact
12-partition `blkdevparts=` A/B layout and the `/dev/block/by-name/` contract.
Where the seams meet: the guest runs the *real* binaries (static musl flared,
the LVGL fbdev UI); the RS485 bridge (`qemu/rs485-bridge/`) connects a QEMU
serial chardev to `sim/`'s `ModbusSlave`, so the register-level models serve as
the VM's field bus — behavior lives in one place, `sim/`, and the VM consumes
it. Scenario tests: `qemu/tests/boot-smoke.sh` (CI, in kernel-build),
`portal-scenario.sh` (check-in + OTA offer download against flare-edge's mock
portal), `ui-shot.sh` (QMP screendump + touch injection). Division of labour
with the other sims: NPU/RGA/HPMCU *behavior* stays `sim/`; UI *rendering
development* stays `lvglsim`; the VM is where processes, the kernel, and the
network meet. §5 still applies — no behavioural sim, this one included, catches
memory-map faults; and "boots under emulation" is never on-silicon evidence.
## 8. Order of work
1. **Simulator core**`membus`, `hpmcu`, the `cru` reset ladder, `modbus`, plus the
`rga`/`npu` models. **Done.**
2. **C-driver MC/DC harnesses**`relays.c` and `freshness.c` at 100% MC/DC, CI-gated
via the shared `drivers/enforce-mcdc.sh`. **Done** (the first C coverage gate).
3. **flared devmem/hpmcu seam + tests** — firmware-side trait, unified with `sim/`
once flare-edge consumes warden-sdk (a separate, [maintainer]-gated step). **Pending.**
once flare-edge consumes warden-sdk (a separate, maintainer-gated step). **Pending.**
4. **Config-lint CI gates** (§5) — the brick-class of bug. **Done.**
5. **Hermetic kernel build** (`build/build-kernel.sh` + the `patches-apply` gate). **Done.**
6. **Kernel 5.10→6.18.46 forward-port** (§6, ADR-0001). **Done** (hardware-verified).
7. **QEMU device sim** (§7, ADR-0006) — boot smoke, A/B disk harness, RS485 bridge,
portal/watchdog/clock scenarios, display+touch. **Done** (emulation-verified;
booting the real flare-edge rootfs+oem image pair is a documented later milestone).
+18 -36
View File
@@ -1,51 +1,33 @@
# CI/CD
`.github/workflows/ci.yml` — everything portable runs on GitHub-hosted
`ubuntu-latest`; only the heavy kernel build uses the self-hosted runner.
`.github/workflows/ci.yml` — every job runs on GitHub-hosted `ubuntu-latest`.
No self-hosted runner is (or may be) reachable from this repo's workflows —
on a public repo, a fork PR that gets one approved run could otherwise
execute code on private infrastructure (ADR-0007).
## Jobs
| Job | Runner | What it does |
|---|---|---|
| `test` | ubuntu-latest | `cargo test` (sim + config-lint) + `cargo-llvm-cov` line coverage on `sim`; outputs `passed`/`coverage`. |
| `test` | ubuntu-latest | `cargo test` (sim + config-lint + qemu/rs485-bridge) + `cargo-llvm-cov` line coverage on `sim`; outputs `passed`/`coverage`. |
| `mcdc` | ubuntu-latest | 100% MC/DC enforced on every `drivers/*/test` (gcc-14 `-fcondition-coverage`). |
| `bench` | ubuntu-latest | Smoke-runs the sim micro-benchmarks; emits ns/op trend JSON. |
| `bench` | ubuntu-latest | Smoke-runs the sim + rs485-bridge micro-benchmarks; emits ns/op trend JSON. |
| `patches-apply` | ubuntu-latest | Fetches pristine linux-6.18.46 (cached, sha256-verified) and applies `patches/*` in order. |
| `kernel-build` | **[self-hosted, warden-sdk]** | `build/build-kernel.sh``zImage` + `rv1106-warden.dtb`, uploaded as an artifact. Dispatch-gated until the runner is fully provisioned (below). |
| `qemu-tools` | ubuntu-latest | shellcheck on `qemu/**.sh`; builds the initramfs (pinned busybox) and the A/B disk image. |
| `kernel-build` | ubuntu-latest, **dispatch-only** | apt-installs the cross toolchain + qemu, `build/build-kernel.sh``zImage` + `rv1106-warden.dtb`, QEMU `-M virt` boot smoke (fail-closed), artifact upload (best-effort). Trigger: `gh workflow run ci.yml`. |
| `prune-artifacts` | ubuntu-latest, dispatch-only | Deletes `kernel-rv1106` artifacts beyond the newest 3. |
| `badges` | ubuntu-latest | Renders loc/tests/coverage shields on push to `main` (`[skip ci]` + `paths-ignore` loop guard). |
## The self-hosted runner (`bfe-mpc-0640`)
## History: the self-hosted runner (retired)
A **third** repo-scoped runner instance on `bfe-mpc-0640` (alongside `flare` and
`flare-edge`), registered with the label **`warden-sdk`** as
`bfe-mpc-0640-warden-sdk`, in `~/actions-runner-warden-sdk`.
> **INSTALLED + ONLINE (2026-08-25).** The runner is a running systemd service
> (`actions.runner.bfe-noah-warden-sdk.bfe-mpc-0640-warden-sdk.service`, `enabled`,
> cgroup-capped `CPUQuota=400%`/`MemoryMax=6G`) and `kernel-build` has been verified
> end-to-end (RV1106 6.18.46 → `zImage` 8.25 MB + `rv1106-warden.dtb`). Steps 12
> below are the record of that install (they needed `user`'s sudo on 0640); steps
> 34 are handled inside the workflow, so the host needs no manual toolchain/python.
1. **Install as a service** (persistence): `cd ~/actions-runner-warden-sdk &&
sudo ./svc.sh install user && sudo ./svc.sh start`. Until then the runner is
*offline* and `kernel-build` only runs when dispatched against an online runner.
2. **Resource cap** (protect the shared host): a drop-in at
`/etc/systemd/system/actions.runner.bfe-noah-warden-sdk.*.service.d/*.conf` with
`CPUQuota=400%` + `MemoryMax=6G`, then `sudo systemctl daemon-reload`. The build
inherits that cgroup. (The workflow also passes `JOBS=4` as a belt-and-braces bound.)
3. **Kernel cross toolchain** — done in the workflow: the `kernel-build` job sets
`CROSS_COMPILE=arm-linux-gnueabihf-` (Debian `gcc-arm-linux-gnueabihf`, already on
the runner) and `build-kernel.sh` honors it. The kernel is freestanding, so the
generic arm cross compiler links it — no Luckfox SDK toolchain path needed. (To use
the SDK uclibc toolchain instead, set `SDK_TC` to its `bin/` and drop the override.)
4. **`python`** (not python3) — done in the workflow: the `kernel-build` job symlinks
`python``python3` into `$RUNNER_TEMP/bin` and prepends it to `$GITHUB_PATH`. No
host-side venv/shim needed.
Host build deps: `dtc bc flex bison libssl-dev` — already present on 0640.
`kernel-build` originally ran on a repo-scoped self-hosted runner (ADR-0004,
2026-08-25, verified end-to-end) because hosted minutes were metered on the
private repo. Going public made hosted minutes free and made a self-hosted
registration a liability, so ADR-0007 moved the job to `ubuntu-latest` and
retired the registration. Site-specific install records for that runner live
in our private deployment log, not here.
## Badges
Static shields SVGs are committed by the `badges` job (private repo can't use
dynamic shields). The GitHub-native `ci.yml` status badge works live regardless.
Static shields SVGs are committed by the `badges` job. The GitHub-native
`ci.yml` status badge works live regardless.
+5 -3
View File
@@ -1,6 +1,8 @@
# ADR 0003 — warden-sdk is a standalone repo
**Status:** Accepted (2026-08-25).
**Status:** Accepted (2026-08-25). Repo-visibility half superseded by ADR-0007
(2026-08-30) — warden-sdk went public; the "private for now" consequence below
no longer holds. Original decision kept for the record.
## Context
Our real SDK changes lived as uncommitted edits in a 2GB opaque vendor fork, with
@@ -10,11 +12,11 @@ no CI, tests, or versioning of their own. The SDK requirement (future-features-2
## Decision
A **private** `bfe-noah/warden-sdk` GitHub repo, standalone from day one with its own
CI/versioning. Work lands on a `bringup` branch; the first commit to `main` is gated
on a passing code-review-harness run, green CI, and [maintainer]'s fresh explicit go-ahead.
on a passing review run, green CI, and the maintainer's fresh explicit go-ahead.
## Consequences
- flare-edge consumes warden-sdk later (flared depending on `warden-sim`, drivers
built from here) — a separate, [maintainer]-gated integration step; flare-edge is not
built from here) — a separate, maintainer-gated integration step; flare-edge is not
edited by the SDK-completion effort.
- Private for now (references bench devices / in-progress hardening); can be opened
later once scrubbed, matching how `flare-deployment` is handled.
+3 -1
View File
@@ -1,6 +1,8 @@
# ADR 0004 — CI/CD runner: 3rd repo-scoped self-hosted runner on 0640
**Status:** Accepted (2026-08-25).
**Status:** Superseded in part by ADR-0007 (2026-08-30) — `kernel-build` moved
to GitHub-hosted runners for the public repo and the self-hosted registration
is retired. Original decision below, kept for the record. (2026-08-25.)
## Context
The heavy kernel/firmware build needs the SDK toolchain and Buildroot's baked-in
@@ -12,7 +12,7 @@ scratch tree (`flare-edge/research/linux-6.18.46/`).
Bring **hardened copies into `warden-sdk/drivers/`** as the canonical source-of-truth,
each with its HAL seam and a 100% MC/DC host harness. The RV1106 kernel deltas are
formalized as a patch series in `patches/`. flare-edge consumes warden-sdk later
(separate, [maintainer]-gated step).
(separate, maintainer-gated step).
## Consequences
- Realizes the seam architecture (ADR-referenced in `docs/architecture.md`).
+58
View File
@@ -0,0 +1,58 @@
# ADR 0006 — QEMU device sim: generic `-M virt`, entered at the kernel
**Status:** Accepted (2026-08-29).
## Context
The two existing simulators cannot test the *device*: `lvglsim` (flare-edge)
is an SDL rendering harness, and `sim/` models registers behind driver seams.
Init ordering, the daemons as real processes, networking/enrollment against
FLARE, OTA, and the watchdog were testable only on a bench panel — flare-edge's
fault suite marks five scenarios "HIL, human prompts", its Playwright e2e needs
a live panel on the LAN, and its OTA desk test stops at "reached APPLYING".
Two ways to emulate the panel were considered:
1. **A custom RV1106 QEMU board model.** Nothing exists upstream or in the
community, so this means writing VOP/CRU/GRF/eMMC/HPMCU device models from
scratch and maintaining them against QEMU — months of work that duplicates
what `sim/` already models in Rust. It still could not run the boot chain:
BootROM is mask ROM and the DDR-init/idblock stages are closed rkbin blobs.
2. **The generic `-M virt` machine, entering at `-kernel zImage`.** The
forward-ported 6.18.46 config is multi_v7-derived and already carries
`ARCH_VIRT` plus the full virtio set — the canonical RV1106 zImage boots
virt **unmodified** (verified 2026-08-29). Peripherals become virtio
substitutes; SoC-block behavior stays in `sim/`, bridged in (the RS485
chardev bridge) rather than re-modeled.
## Decision
Option 2. `qemu/` holds the harness: `qemu-system-arm -M virt,highmem=off
-cpu cortex-a7 -smp 1 -m 256M` (the RV1106G3's shape), one canonical kernel
image plus an optional additive config fragment (`qemu/configs/virt.fragment`
via the `WARDEN_KCONFIG_FRAGMENT` hook — PCI/pci-serial/i6300esb/WireGuard/
virtio-gpu/virtio-input; the RV1106 build is byte-identical with the variable
unset). The VM carries the device's real 12-partition `blkdevparts=` A/B
layout on a virtio disk and populates the `/dev/block/by-name/` contract.
The name is `qemu/`, not any variant of "sim" — the wikis already warn that
"sim" is two different things.
Notable mechanics: `highmem=off` because the non-LPAE 32-bit kernel cannot
reach virt's default 40-bit PCIe ECAM; `-global virtio-mmio.force-legacy=false`
because virtio-gpu/input are VERSION_1-only devices.
## Consequences
- Everything below the kernel is **out of scope**: BootROM, idblock, U-Boot,
the real BCB-driven A/B selection and bootcount auto-revert. The initramfs
`warden.slot=` switch emulates U-Boot's *choice*, not the mechanism. The
untested A/B rollback chain stays bench territory.
- Display, input, network, and storage are **substitutes** (virtio), not
models. "Boots/works under emulation" is never evidence of "works on
silicon"; the VM narrows which claims need a panel.
- The VM is the first environment that runs production userspace binaries on
a non-RV1106 physical memory map, which makes it a canary for baked-in
hardware assumptions (it immediately found flare-edge #106, a fatal SIGBUS
in flared's HPMCU probe, and #107, a Y2038 time_t truncation in the UI).
- The guest deliberately deviates from production in documented ways
(`WARDEN_FLARE_INSECURE=1` for the desk mock portal, `WARDEN_HPMCU=0`);
qemu/README.md carries the emulated-vs-not table.
- The kernel-build CI job gains a fail-closed qemu boot smoke; hosted runners
build (but cannot boot) the initramfs and disk image.
@@ -0,0 +1,37 @@
# ADR 0007 — Public repo: kernel-build moves to GitHub-hosted runners
**Status:** Accepted (2026-08-30). Supersedes the runner half of ADR-0004.
## Context
The repo is going public (free Actions minutes for hosted runners; open-source
alignment with the stack philosophy). Two facts change the ADR-0004 calculus:
1. **A self-hosted runner on a public repo is a standing hazard.** A fork PR
can modify workflow files; once any run of theirs is approved, workflows
can target the repo's registered self-hosted runners — i.e. arbitrary code
on the private host, which also serves production. GitHub's own guidance is
to never attach self-hosted runners to public repos, and personal-account
repos have no runner groups to scope the risk away.
2. **The build never needed the SDK host.** ADR-0004's premise ("needs the SDK
toolchain and Buildroot's baked-in absolute paths") does not apply to
`kernel-build`: the hermetic build is freestanding, uses Debian's
`gcc-arm-linux-gnueabihf`, and self-provisions `python`. It fits a hosted
runner (4 vCPU / 16 GB), and public-repo minutes are free.
## Decision
`kernel-build` runs on `ubuntu-latest`, apt-installing its toolchain, kernel
build deps, and qemu-system-arm, with the pristine tarball cached like
`patches-apply` does. It stays `workflow_dispatch`-only for now (a full build
per push is still noisy; flipping it to push-on-main later is one line). The
`warden-sdk` self-hosted runner instance is **deregistered from this repo**
before it goes public; the flare and flare-edge runner instances on the same
host are unaffected (those repos stay private).
## Consequences
- No path from public workflows to private infrastructure; nothing to babysit
in fork-PR approval settings beyond GitHub's defaults (still set "require
approval for all outside contributors" as belt-and-braces).
- Kernel artifacts no longer persist on the runner host; the GitHub artifact
(5-day retention + prune job) is the only build output channel.
- Hosted kernel builds are slower than the 0640 box but free and parallel;
the boot-smoke step rides along unchanged.
+1 -1
View File
@@ -38,7 +38,7 @@ is already modelled and tested here** in `../sim/`:
**Why the Tier-2 *source* isn't vendored here yet:** `modbus_engine.c` and
`warden_rga.c` pull in shared UI headers (`platform.h`, `settings.h`, `lv_*`) and
librga. Copying those in would duplicate exactly the shared surface the
**flare-edge↔warden-sdk unification** (ADR-0003/0005, a separate [maintainer]-gated step) is
**flare-edge↔warden-sdk unification** (ADR-0003/0005, a separate maintainer-gated step) is
meant to resolve cleanly. So the Tier-2 *models* (the hardware ends) live here now;
the Tier-2 *driver sources* migrate in with the unification, at which point their
existing flare-edge harnesses point at this repo.
+1 -1
View File
@@ -52,5 +52,5 @@ if ! echo "$cond_line" | grep -q "100.00%"; then
rc=1
fi
[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green "
[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green OK"
exit "$rc"
+1 -1
View File
@@ -1,6 +1,6 @@
# RV1106 → Linux 6.18 forward-port (self-built, from vendor 5.10)
**Decision ([maintainer], 2026-08-23):** forward-port the RV1106 SoC enablement from the
**Decision (2026-08-23):** forward-port the RV1106 SoC enablement from the
Rockchip **vendor 5.10** tree straight to **Linux 6.18 LTS**, ourselves, using **no
plan44 code**, built on **our Buildroot 2025.02 LTS + uClibc**. We own the tree.
+8 -6
View File
@@ -7,9 +7,10 @@ findings — they worked on the first hardware try.**
## The safe test path (A/B slot _b, never touch _a)
c8a3 is the XPS-connected Warden: eth0 `[bench-ip]` over USB-gadget ([bench-creds],
dropbear — use `scp -O`, no sftp-server), plus the serial console on xps
`/dev/ttyUSB2` @115200 and Zigbee power (`plug_cmd.py cycle`). It runs an A/B
c8a3 is the desk-connected bench Warden: reachable over its USB-gadget ethernet
(dev-build dropbear; address and bench credentials live in the private
deployment notes — use `scp -O`, no sftp-server), plus the serial console at
115200 and remotely switchable power. It runs an A/B
firmware (boot_a=mmcblk0p5 / boot_b=mmcblk0p6, 32 MiB each; rootfs_a/_b; AvbABData
in `misc` sector 4 / byte 2048).
@@ -51,7 +52,7 @@ With the correct format, U-Boot loaded my kernel + my DTB and printed my DT mode
string (`Model: WardenOS 86-Panel (RV1106) — M2 earlycon bring-up`), then
`Starting kernel ...`.
## Result: M2 achieved — the 6.18 kernel boots on hardware
## Result: [x] M2 achieved — the 6.18 kernel boots on hardware
Six attempts, each auto-recovering to `_a`, then a clean boot:
@@ -84,7 +85,7 @@ pre-MMU), then fixed:
CP2102 bench adapter — so the M2 DT pins 115200 for readable bring-up; production
overrides to 1.5M.
## M3 (same session): the full WardenOS runs on the 6.18 kernel
## M3 (same session): [x] the full WardenOS runs on the 6.18 kernel
Adding the eMMC `dw_mmc` node (`mmc@ffa90000`, clocks from cru + grf_cru) was the
only change M3 needed — the mmc/ext4 drivers are already in-config. The kernel
@@ -96,7 +97,8 @@ won't load (vermagic → M5), and there's no backlight/framebuffer yet (→ M4).
**Console lesson applied:** with `console=ttyS2,115200` the whole boot is readable
on the CP2102 (the 1.5M vendor rate is garbage on it). Serial login uses the same
`c8a3_run.py` [bench-creds] helper as the 5.10 firmware — the userspace is unchanged.
`c8a3_run.py` helper (dev-build bench credentials, see private deployment notes)
as the 5.10 firmware — the userspace is unchanged.
Next: M4 (VOP2 display + panel + touch), M5 (AIC8800 SDIO port + our 4 patches),
M6 (RGA/watchdog/HPMCU/USB-OTG); plus a lean defconfig + Buildroot-on-6.18 cleanup.
+11 -11
View File
@@ -4,44 +4,44 @@ Every block in the vendor SoC DT (`rv1106.dtsi`), classified. Goal: a 6.18 drive
for every capability the hardware actually has, open-source, verified. Camera/ISP
is the only whole class deliberately skipped — the 86-Panel has no camera.
## Done / at parity (verified on warden-c8a3)
## Done / at parity (verified on warden-c8a3)
CRU clk · pinctrl (+ioc/pmuioc) · GIC · arch timer · pl330 DMA · 8250 uart (×3) ·
dw_mmc eMMC · i2c · dw-wdt · **RTC** · **tsadc** · **RGA** (rga2, hw 3.3.87975) ·
PWM backlight · **USB host** (dwc3/xhci) + usb2phy · grf/pmu syscons.
## Verified this run (2026-08-25) — all on warden-c8a3, self-built 6.18.46
## Verified this run (2026-08-25) — all on warden-c8a3, self-built 6.18.46
- **AIC8800 wifi** — wlan0 up, scanned the site AP at 43 dBm (modules; `wifi/VERIFIED-on-c8a3.md`).
- **TRNG** — /dev/hwrng, real HW entropy (`rng-otp/`).
- **OTP/nvmem** — rockchip-otp0 reads chip id (`rng-otp/`).
- **GMAC** — eth0 Link Up 100 Mbps/Full (`gmac/`).
- **SARADC** — iio:device0 reads 2 ch; the 22 was vref, not clk (`adc/SARADC-FIX.md`).
## VOP display — VERIFIED this run (2026-08-25)
## VOP display — VERIFIED this run (2026-08-25)
Full WardenOS Dashboard renders on the 86-Panel on 6.18 (`_b`), webcam-verified,
pixel-identical to stock `_a`. Two VOP driver bugs were the final black-screen
cause: `rgb_dclk_pol` hardcoded inverted (panel needs 0), and the wrong primary
scanout window (rv1106 uses **WIN1**, not rv1126's WIN2). Full chain + `_a`-vs-`_b`
register diff in `display/VERIFIED.md`.
## GT911 touch — VERIFIED this run (2026-08-25)
UI responds to taps/swipes on the panel ([maintainer]-confirmed); GT911 detected
## GT911 touch — VERIFIED this run (2026-08-25)
UI responds to taps/swipes on the panel (confirmed by hand at the panel); GT911 detected
(`ID 911, version 1060`), `/dev/input/event0` held by warden-ui. Fix:
`CONFIG_TOUCHSCREEN_GOODIX=y` (built-in — the rootfs `goodix.ko` is a 5.10 build
that can't load on 6.18) + GT911 node on `&i2c3`. Details in `touch/VERIFIED.md`.
## 🔨 In flight
## In flight
- **i2s-tdm** — DAI builds; needs the codec + card (below).
- **AIC8800 BT** — module built (6.18 vermagic); HCI bring-up not yet exercised.
## audio — VERIFIED this run
## audio — VERIFIED this run
card `rv1106-acodec` + pcmC0D0p/c (`audio/`); audible test @ bench with display.
## Remaining blocks — final status (all real capabilities now verified)
| Block | verdict | note |
|---|---|---|
| **mailbox** (HPMCU) | VERIFIED | A7↔RISC-V-SCR1 round-trip, 5/5 exact echoes (`mailbox/VERIFIED.md`). Took 3 hardware-found fixes: rv1106 num_chans=1 (1 shared IRQ, not 4), CLK_CORE_MCU IGNORE_UNUSED (6.18 was gating the coprocessor clock), + an open SCR1 echo firmware with A2B_INTEN. The /dev/mem SRAM watchdog stays as a separate dead-man's-switch. |
| **NPU** (rknpu) | open driver VERIFIED; compute deferred | open GPL rknpu 0.9.2 driver, /dev/dri/card1, version ioctl PASS (`npu/VERIFIED.md`). Open *compute* (a regcmd compiler) is a from-scratch ~person-year register-RE project — no RV1106 prior art, no public TRM Part 2, mainline accel/rocket+Teflon are RK3588-only. Ship the driver, no blob. |
| **pvtm** | VERIFIED | both core+pmu PVT monitors probe; debugfs ring-osc reads (`pvtm/PORT-DONE.md`). |
| **mailbox** (HPMCU) | VERIFIED | A7↔RISC-V-SCR1 round-trip, 5/5 exact echoes (`mailbox/VERIFIED.md`). Took 3 hardware-found fixes: rv1106 num_chans=1 (1 shared IRQ, not 4), CLK_CORE_MCU IGNORE_UNUSED (6.18 was gating the coprocessor clock), + an open SCR1 echo firmware with A2B_INTEN. The /dev/mem SRAM watchdog stays as a separate dead-man's-switch. |
| **NPU** (rknpu) | [x] open driver VERIFIED; compute deferred | open GPL rknpu 0.9.2 driver, /dev/dri/card1, version ioctl PASS (`npu/VERIFIED.md`). Open *compute* (a regcmd compiler) is a from-scratch ~person-year register-RE project — no RV1106 prior art, no public TRM Part 2, mainline accel/rocket+Teflon are RK3588-only. Ship the driver, no blob. |
| **pvtm** | VERIFIED | both core+pmu PVT monitors probe; debugfs ring-osc reads (`pvtm/PORT-DONE.md`). |
| **crypto-v3** (accel) | deferred (documented) | ~100 KB whole-subsystem replacement of mainline's rk3288 crypto + heavy crypto-API deltas; the **CPU crypto extensions (AES/SHA, batch2 =y) already cover the functional need** — an offload optimization, not a capability gap. |
| camera/ISP, SPI | N/A | no such hardware on the 86-Panel. |
@@ -51,7 +51,7 @@ deferred item is the crypto *accelerator* (CPU crypto already covers it) and ope
NPU *compute* (a person-year RE effort, scoped in `npu/OPEN-NPU-PLAN.md`). No
unexplored gap remains.
## Not applicable (no hardware on the 86-Panel)
## Not applicable (no hardware on the 86-Panel)
cif · csi2-dphy · mipi-csi2 · rkisp (all camera/ISP) · SPI (no on-board SPI device).
## Order (after wifi)
+39 -39
View File
@@ -3,47 +3,47 @@
Goal: every driver the panel's 5.10 kernel runs must work at ≥ parity on our
self-built 6.18. Source of truth = the running 5.10 system on warden-c8a3
(`lsmod` + `/proc/interrupts`, captured 2026-08-24). Verify each on hardware via
the A/B `_b`-slot loop (`../docs/m2-boot-on-c8a3.md`); means confirmed on
the A/B `_b`-slot loop (`../docs/m2-boot-on-c8a3.md`); [x] means confirmed on
c8a3, not just compiled.
| Driver / node | 5.10 evidence | mainline? | 6.18 status |
|---|---|---|---|
| CRU clock (clk-rv1106) | — | ported | M2 |
| pinctrl-rockchip (rv1106) | — | ported | M2 |
| GIC-400 / arch_timer | arch_timer | mainline | M2 |
| dw_mmc (eMMC) | dw-mci | mainline | M3 |
| 8250 uart2 (console) | ttyS2 | mainline | M2 |
| GPIO (rockchip, ×5 banks) | gpio-rockchip | mainline | batch1 (chips 04) |
| DMA (pl330, ff420000) | ff420000.dma-controller | mainline | batch1 |
| uart1 / uart4 | ttyS1, ttyS4 | mainline | batch1 |
| I2C (dw-apb, ff460000=i2c3) | ff460000.i2c | mainline | batch1 (i2c-3) |
| watchdog (dw-wdt, ff5a0000) | ff5a0000.watchdog | mainline | batch1 (watchdog0) |
| tsadc thermal (ff3c8000) | rockchip_thermal | ported (data+init+macros) | soc-thermal reads 39.8°C |
| SARADC (ff3c0000) | ff3c0000.saradc | ported (2-ch v2 data) | iio:device0 reads 2ch (adc-keys); fixed -22 via vref-supply |
| TRNG (rng@ff448000) | rockchip,trngv1 | mainline (rk3588 IP) | /dev/hwrng, real entropy (`rng-otp/`) |
| OTP/nvmem (ff3d0000) | rockchip,rv1106-otp | ported (px30_otp_read) | rockchip-otp0, reads chip id |
| GMAC (ffa80000) | rockchip,rv1106-gmac | ported (dwmac-rk rv1106_ops) | eth0 Link Up 100M/Full (`gmac/`) |
| GPIO_SYSFS (legacy /sys/class/gpio) | — | mainline (config) | goodix script needs it |
| PWM (rockchip) | — | mainline (=m) | batch2 =y (backlight) |
| RTC (rv1106-rtc) | — | ported (vendor driver) | /dev/rtc0 registers + reads |
| USB2 phy (inno, rv1106) | rockchip_usb2phy_* | ported (data, no tuning) | probes → USB up |
| USB host (DWC3→xhci, ffb00000) | xhci-hcd:usb1 | mainline | xhci host registered |
| USB OTG gadget (DWC3, eth0) | eth0 | mainline dwc3 | 🔨 host works; eth0 needs dr_mode=peripheral |
| crypto (aes/ccm/ctr/arc4) | modules | mainline | batch2 (config =y) |
| PSCI node (removed) | — | — | deleted (no secure monitor → SMC fault) |
| VOP display (ff990000) | ff990000.vop | ported (rv1126 sibling) | 🔨 binds+DRM+card0; connector WIP |
| PWM backlight (pwm1) | — | mainline (rk3328 fallback) | backlight up (brightness) |
| RGB666 720×720 panel | — | panel-dpi | 🔨 probes; bus_format + connector WIP |
| GT911 touch (goodix) | goodix, gt911 | mainline | M4 (needs GPIO_SYSFS + node) |
| GPIO_SYSFS / crypto / CFG80211 | — | mainline (config) | =y (batch2) |
| AIC8800 wifi (bsp/fdrv) | aic8800_* | **out-of-tree** | M5 — wlan0 up, scanned the site AP at 43dBm (modules, `wifi/VERIFIED-on-c8a3.md`) |
| AIC8800 BT (btlpm) | aic8800_btlpm | **out-of-tree** | 🔨 module built (6.18 vermagic); HCI bring-up not yet exercised |
| NPU (rknpu, ff660000) | rknpu, ff660000.npu | **out-of-tree** | 🔨 M6 built, 0 errors/0 warnings, 99 `rknpu`-prefixed symbols in `System.map`, `&npu {status="okay"}` in the dtb — **not yet flashed/probed on hardware** (build-only session; see `npu/PORT-PROGRESS.md`) |
| RGA 2D (rga2) | rga2 | ported (vendor char-dev) | /dev/rga, hw 3.3.87975 |
| I2S audio (i2s-tdm) | i2s | rv1126 fallback (=y) | cpu DAI registers (part of the card below) |
| Audio codec (acodec) | rockchip,rv1106-codec | ported (rv1106_codec.c) | card `rv1106-acodec`, pcmC0D0p/c (`audio/`); audible test @ bench |
| HPMCU mailbox (ff5c0000) | rockchip,rv1106-mailbox | rk3368 fallback +rv1106 num_chans=1 | A7<->SCR1 round-trip, 5/5 exact (`mailbox/VERIFIED.md`) |
| PVTM (core+pmu ring-osc) | rockchip,rv1106-*-pvtm | ported (vendor, no mainline) | both probe; debugfs reads (`pvtm/`) |
| FIQ debugger (ttyFIQ0) | fiq_glue | rockchip | optional (we use ttyS2) |
| CRU clock (clk-rv1106) | — | ported | [x] M2 |
| pinctrl-rockchip (rv1106) | — | ported | [x] M2 |
| GIC-400 / arch_timer | arch_timer | mainline | [x] M2 |
| dw_mmc (eMMC) | dw-mci | mainline | [x] M3 |
| 8250 uart2 (console) | ttyS2 | mainline | [x] M2 |
| GPIO (rockchip, ×5 banks) | gpio-rockchip | mainline | [x] batch1 (chips 04) |
| DMA (pl330, ff420000) | ff420000.dma-controller | mainline | [x] batch1 |
| uart1 / uart4 | ttyS1, ttyS4 | mainline | [x] batch1 |
| I2C (dw-apb, ff460000=i2c3) | ff460000.i2c | mainline | [x] batch1 (i2c-3) |
| watchdog (dw-wdt, ff5a0000) | ff5a0000.watchdog | mainline | [x] batch1 (watchdog0) |
| tsadc thermal (ff3c8000) | rockchip_thermal | ported (data+init+macros) | [x] soc-thermal reads 39.8°C |
| SARADC (ff3c0000) | ff3c0000.saradc | ported (2-ch v2 data) | [x] iio:device0 reads 2ch (adc-keys); fixed -22 via vref-supply |
| TRNG (rng@ff448000) | rockchip,trngv1 | mainline (rk3588 IP) | [x] /dev/hwrng, real entropy (`rng-otp/`) |
| OTP/nvmem (ff3d0000) | rockchip,rv1106-otp | ported (px30_otp_read) | [x] rockchip-otp0, reads chip id |
| GMAC (ffa80000) | rockchip,rv1106-gmac | ported (dwmac-rk rv1106_ops) | [x] eth0 Link Up 100M/Full (`gmac/`) |
| GPIO_SYSFS (legacy /sys/class/gpio) | — | mainline (config) | [ ] goodix script needs it |
| PWM (rockchip) | — | mainline (=m) | [ ] batch2 =y (backlight) |
| RTC (rv1106-rtc) | — | ported (vendor driver) | [x] /dev/rtc0 registers + reads |
| USB2 phy (inno, rv1106) | rockchip_usb2phy_* | ported (data, no tuning) | [x] probes → USB up |
| USB host (DWC3→xhci, ffb00000) | xhci-hcd:usb1 | mainline | [x] xhci host registered |
| USB OTG gadget (DWC3, eth0) | eth0 | mainline dwc3 | [wip] host works; eth0 needs dr_mode=peripheral |
| crypto (aes/ccm/ctr/arc4) | modules | mainline | [ ] batch2 (config =y) |
| PSCI node (removed) | — | — | [x] deleted (no secure monitor → SMC fault) |
| VOP display (ff990000) | ff990000.vop | ported (rv1126 sibling) | [wip] binds+DRM+card0; connector WIP |
| PWM backlight (pwm1) | — | mainline (rk3328 fallback) | [x] backlight up (brightness) |
| RGB666 720×720 panel | — | panel-dpi | [wip] probes; bus_format + connector WIP |
| GT911 touch (goodix) | goodix, gt911 | mainline | [ ] M4 (needs GPIO_SYSFS [x] + node) |
| GPIO_SYSFS / crypto / CFG80211 | — | mainline (config) | [x] =y (batch2) |
| AIC8800 wifi (bsp/fdrv) | aic8800_* | **out-of-tree** | [x] M5 — wlan0 up, scanned the site AP at 43dBm (modules, `wifi/VERIFIED-on-c8a3.md`) |
| AIC8800 BT (btlpm) | aic8800_btlpm | **out-of-tree** | [wip] module built (6.18 vermagic); HCI bring-up not yet exercised |
| NPU (rknpu, ff660000) | rknpu, ff660000.npu | **out-of-tree** | [x] open GPL driver VERIFIED on hardware — `/dev/dri/card1`, `rknpu_version_test` PASS (power/clock/reset path exercised); open *compute* (regcmd) remains a from-scratch RE project (`npu/VERIFIED.md`, `npu/OPEN-NPU-PLAN.md`) |
| RGA 2D (rga2) | rga2 | ported (vendor char-dev) | [x] /dev/rga, hw 3.3.87975 |
| I2S audio (i2s-tdm) | i2s | rv1126 fallback (=y) | [x] cpu DAI registers (part of the card below) |
| Audio codec (acodec) | rockchip,rv1106-codec | ported (rv1106_codec.c) | [x] card `rv1106-acodec`, pcmC0D0p/c (`audio/`); audible test @ bench |
| HPMCU mailbox (ff5c0000) | rockchip,rv1106-mailbox | rk3368 fallback +rv1106 num_chans=1 | [x] A7<->SCR1 round-trip, 5/5 exact (`mailbox/VERIFIED.md`) |
| PVTM (core+pmu ring-osc) | rockchip,rv1106-*-pvtm | ported (vendor, no mainline) | [x] both probe; debugfs reads (`pvtm/`) |
| FIQ debugger (ttyFIQ0) | fiq_glue | rockchip | [ ] optional (we use ttyS2) |
Legend: verified on hardware · 🔨 built, not yet verified · not started.
Legend: [x] verified on hardware · [wip] built, not yet verified · [ ] not started.
+1 -1
View File
@@ -1,6 +1,6 @@
# Overnight autonomous kernel-completion workflow
**Directive ([maintainer], 2026-08-24 night):** port/enable **every** remaining RV1106
**Directive (2026-08-24 night):** port/enable **every** remaining RV1106
hardware capability on the self-built 6.18 kernel, **open-source-first** — we want
*source* we can read, harden, and bend to our needs (vendor SDK source, upstream,
community repos, or reverse-engineering), never binary blobs. Not just ports —
@@ -1,13 +1,13 @@
# Overnight kernel-enablement run — results (2026-08-24 night → 08-25)
Goal ([maintainer]): port/enable **every** remaining RV1106 hardware capability on the
Goal: port/enable **every** remaining RV1106 hardware capability on the
self-built Linux 6.18.46, open-source-first, verify on hardware, so the display's
last mile can start in the morning. Runs on warden-c8a3 (`_b` slot = our 6.18).
## Verified on hardware this run
## Verified on hardware this run
| Driver | Evidence |
|---|---|
| **AIC8800 wifi** (M5) | wlan0 up ([device-mac]), `iw scan` found the site AP 43 dBm + others. Modules (built-in deadlocks the two-stage SDIO bring-up). |
| **AIC8800 wifi** (M5) | wlan0 up, `iw scan` found the site AP at 43 dBm + others. Modules (built-in deadlocks the two-stage SDIO bring-up). |
| **TRNG** | `/dev/hwrng`, `rng_current=rockchip-rng`, real entropy. |
| **OTP/nvmem** | `rockchip-otp0` reads chip id ("MR1"). |
| **GMAC** (wired eth) | `eth0: Link is Up - 100 Mbps/Full`. |
@@ -33,7 +33,7 @@ mailbox (binds via rk3368 fallback but no client to exercise), crypto-v3 (CPU
crypto extensions already cover it; 100 KB port), NPU (no open userspace),
pvtm (DVFS-only). Camera/ISP/SPI: no hardware. **No unexplored capability gap.**
## Morning (needs [maintainer] at the bench)
## Morning (needs a human at the bench)
1. **Display connector** — VOP binds + DRM card0 + panel probes, but no connector
link yet; needs eyes on the panel (pixels can't be verified over serial).
2. **Audible audio**`speaker-test`/`aplay` through the acodec.
+8 -8
View File
@@ -20,7 +20,7 @@ from the **vendor 5.10.160** tree, no plan44 code, built with our
- rv1106's siblings **rv1126/rv1108 exist in both trees**, so their 5.10→6.18 delta is a
working template for the framework API changes.
## M1 — clock driver (`clk-rv1106.c`, 1294 lines): COMPILES CLEAN on 6.18
## M1 — clock driver (`clk-rv1106.c`, 1294 lines): [x] COMPILES CLEAN on 6.18
Build-fix loop against 6.18 — `clk-rv1106.o` (85732 bytes) builds with no errors.
Fixed (captured in `clk/`):
1. **Kconfig + Makefile hooks** — added `CONFIG_CLK_RV1106` (mirrors CLK_RV1126).
@@ -31,7 +31,7 @@ Fixed (captured in `clk/`):
4. **`rockchip_clk_register_armclk` signature change** — 5.10 took
`(num_parents, parent_clk, alt_parent_clk)`; 6.18 takes `(parent_names[], num_parents)`
and drives the mux from `reg_data.mux_core_main/alt`. Adapted the call to the 6.18 form
using a parent-names array (sibling-delta from rv1126). **PORT-VERIFY**: the mux input
using a parent-names array (sibling-delta from rv1126). **PORT-VERIFY**: the mux input
list `{ "gpll","cpll","apll" }` and the `mux_core_main/alt=2` mapping are a best-effort
from the 5.10 intent (main=apll) — they set the **CPU clock source**, so they must be
checked against the RV1106 `CORECLKSEL_CON` register map (TRM) and validated on hardware
@@ -39,9 +39,9 @@ Fixed (captured in `clk/`):
5. **`CLK_FRAC_DIVIDER_NO_LIMIT`** — Rockchip downstream-only frac-divider flag (6 uses on
the UART frac clocks); mainline has no min/max opt-out, mapped to 0 (default limit).
**PORT-VERIFY**: UART fractional baud accuracy.
**PORT-VERIFY**: UART fractional baud accuracy.
## M1 — pinctrl (`pinctrl-rockchip.c/.h`): COMPILES CLEAN on 6.18
## M1 — pinctrl (`pinctrl-rockchip.c/.h`): [x] COMPILES CLEAN on 6.18
`pinctrl-rockchip.o` (173688 bytes) builds no-errors. Transplanted from vendor 5.10 (effort S,
zero API drift — the survey's assessment held): added `RV1106` to the type enum; a 159-line
block of `RV1106_DRV/PULL/SMT_*` macros + 3 `rv1106_calc_*_reg_and_bit()` functions; `case
@@ -58,12 +58,12 @@ RV1106:` in the 3 pull functions + the RK3568 drive-strength group; `rv1106_pin_
- **Still PORT-VERIFY:** GPIO4 bank pin-count (`pin_banks` says 24, DT `gpio-ranges` says 32) —
carried from vendor unchanged; needs TRM/hardware.
## M1 — mach: DONE
## M1 — mach: DONE
`mach-rockchip` RV1106/RV1103 SoC recognition added as a DT-compat entry (no
`CPU_RV1106` symbol recreated). Captured as `mach/0001-rv1106-soc-recognition.patch`.
**M1 is complete: clk + pinctrl + mach all compile clean on 6.18.**
## M2 — earlycon build: DONE (boot pending hardware)
## M2 — earlycon build: DONE (boot pending hardware)
The first full kernel build with our SoC drivers, 2026-08-24:
- **`multi_v7_defconfig` + `configs/m2-earlycon.fragment` builds an 11.8 MB zImage**
with `clk-rv1106.o` (85732 B) and `pinctrl-rockchip.o` (173688 B, our rv1106 data)
@@ -80,7 +80,7 @@ The first full kernel build with our SoC drivers, 2026-08-24:
console baud (1.5M assumed). A wrong DDR/clock value silently hangs before or just after
earlycon.
## M2 — boot: DONE — "it's alive" on warden-c8a3 (2026-08-24)
## M2 — boot: DONE — "it's alive" on warden-c8a3 (2026-08-24)
The self-built **Linux 6.18.46 boots on real RV1106 hardware**, through our ported
drivers, verified over the serial console. It reaches earlycon, the arch timer
(BogoMIPS calibrated), **our `clk-rv1106` CRU driver**, pinctrl, and the mainline
@@ -105,7 +105,7 @@ Three bring-up bugs were found and fixed on hardware, all captured in the DT:
comes up far enough to clock the UART and the arch timer on hardware. A wrong CPU
mux/PLL would show later (cpufreq / peripheral rates), still to be checked.
## M3 — rootfs boot: DONE — the full WardenOS runs on the 6.18 kernel (2026-08-24)
## M3 — rootfs boot: DONE — the full WardenOS runs on the 6.18 kernel (2026-08-24)
Adding the eMMC (`dw_mmc`) node to the DT was all M3 needed — the drivers are already
in the config. On hardware:
```
+1 -1
View File
@@ -1,6 +1,6 @@
# Driver provenance & openness ledger
[maintainer]'s directive: **every** driver we run on the self-built 6.18 kernel must be
Standing directive: **every** driver we run on the self-built 6.18 kernel must be
open source we can read, harden, and bend — not a binary blob — and this applies
to the drivers *already* ported, not just the new ones. This ledger records, for
each, where the source came from and under what license. Every entry is GPL-2.0
+1 -1
View File
@@ -1,4 +1,4 @@
# SARADC — VERIFIED on warden-c8a3 (2026-08-25); the -22 was vref, not clk
# SARADC — VERIFIED on warden-c8a3 (2026-08-25); the -22 was vref, not clk
The rockchip_saradc probe failed `-22` NOT at clk_set_rate (no "failed to set
adc clk rate" ever printed) but at `regulator_get_voltage(info->vref)` — with no
@@ -224,7 +224,7 @@ stuck in `deferred probe pending: asoc-simple-card: parse error`, no card.
i2s0_8ch node already carries the `rockchip,rv1126-i2s-tdm` fallback compatible +
`#sound-dai-cells=<0>`, so the mainline driver binds it).
**VERIFIED on c8a3:** `/proc/asound/cards` → `0 [rv1106acodec]: simple-card -
**VERIFIED on c8a3:** `/proc/asound/cards` → `0 [rv1106acodec]: simple-card -
rv1106-acodec`; `aplay -l` → `card 0: rv1106acodec, device 0:
ffae0000.i2s-rv1106-hifi`; `/dev/snd/` has `controlC0 pcmC0D0p pcmC0D0c` (playback
+ capture). Audible speaker test deferred to the bench (with the display).
+1 -1
View File
@@ -8,7 +8,7 @@
# Defaults match this workspace.
set -euo pipefail
FE="${FE:-<flare-edge>}"
FE="${FE:?set FE to a flare-edge checkout path}"
KTREE="${KTREE:-$FE/research/linux-6.18.46}"
SDK_TC="${SDK_TC:-$FE/sdk/tools/linux/toolchain/arm-rockchip830-linux-uclibcgnueabihf/bin}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+1 -1
View File
@@ -39,7 +39,7 @@ rockchip-drm display-subsystem: bound ff990000.vop
brightness settable). **The VOP driver port is validated** — the register data,
version, feature, and resets are right.
## RESOLVED — full UI renders on the panel (2026-08-25)
## RESOLVED — full UI renders on the panel (2026-08-25)
The connector *and* the deeper black-screen chain that followed it are fixed; the
86-Panel now draws the full WardenOS Dashboard on 6.18 (`_b`), verified by webcam.
+1 -1
View File
@@ -1,4 +1,4 @@
# Display (VOP + RGB panel) — VERIFIED on warden-c8a3 (2026-08-25)
# Display (VOP + RGB panel) — VERIFIED on warden-c8a3 (2026-08-25)
The 86-Panel renders the **full WardenOS Dashboard UI** on our self-built Linux
6.18.46 (`_b` slot), pixel-identical to the stock 5.10 `_a` slot. Verified by
+1 -1
View File
@@ -1,4 +1,4 @@
# GMAC (wired 10/100 ethernet) — VERIFIED on warden-c8a3 (2026-08-25)
# GMAC (wired 10/100 ethernet) — VERIFIED on warden-c8a3 (2026-08-25)
**Result: `eth0: Link is Up - 100Mbps/Full - flow control rx/tx`** on our
self-built 6.18.46. The 86-Panel's RMII MAC + on-die 10/100 FEPHY works; a real
+2 -2
View File
@@ -25,7 +25,7 @@ watchdog exactly as-is (different threat model, different job).
- **Controller (Linux side): non-issue.** `drivers/mailbox/rockchip-mailbox.c` is
upstream in mainline 6.18 and **already binds on our exact kernel** via the
generic `rockchip,rk3368-mailbox` **fallback compatible** with **zero patching**
— recorded in `CAPABILITIES-AUDIT.md:30`, confirmed by source read. RV1106's DT
— recorded in `CAPABILITIES-AUDIT.md`'s Remaining-blocks table (mailbox row), confirmed by source read. RV1106's DT
declares both instances with that fallback string. Gated today only by
`status="disabled"` + `CONFIG_ROCKCHIP_MBOX` being absent from the defconfig.
- **HPMCU firmware (MCU side): we already do the hard part.** WardenOS has a
@@ -241,7 +241,7 @@ adopt rpmsg/virtio unless the payload complexity genuinely demands it.
moot, since our `hpmcu.rs` is an independent hardware-validated reimplementation.
---
_Cross-refs: `../CAPABILITIES-AUDIT.md:30`, `../REMAINING-PORTS.md §7`,
_Cross-refs: `../CAPABILITIES-AUDIT.md`, `../REMAINING-PORTS.md §7`,
`../../luckfox-pico-86-panel/riscv-mcu.md`,
`.../raw/followup-riscv-mcu.md`,
`flare-edge/major-app-additions/docs/decisions/0002-hpmcu-watchdog.md`,
+6 -6
View File
@@ -1,4 +1,4 @@
# HPMCU mailbox — 100% VERIFIED on warden-c8a3 (2026-08-25)
# HPMCU mailbox — [x] 100% VERIFIED on warden-c8a3 (2026-08-25)
A fully-open A7 ↔ HPMCU (RISC-V SCR1) hardware-mailbox round-trip on our self-built
Linux 6.18.46. Open kernel driver + open SCR1 firmware, **zero blobs**.
@@ -7,11 +7,11 @@ Linux 6.18.46. Open kernel driver + open SCR1 firmware, **zero blobs**.
SCR1 echo firmware running: `DBG_STATE = 0x584F424D` ("MBOX"). Five round-trips,
Linux → mailbox → SCR1 → mailbox → Linux, **all exact**:
```
sent 0x0000beef/0x600df00d -> B2A 0x0000BEEF/0x600DF00D e=4
sent 0x0000c0de/0x12345678 -> B2A 0x0000C0DE/0x12345678 e=5
sent 0x0000face/0xdeadbeef -> B2A 0x0000FACE/0xDEADBEEF e=6
sent 0x00001234/0xcafef00d -> B2A 0x00001234/0xCAFEF00D e=7
sent 0x0000aa55/0x55aa55aa -> B2A 0x0000AA55/0x55AA55AA e=8
sent 0x0000beef/0x600df00d -> B2A 0x0000BEEF/0x600DF00D e=4 OK
sent 0x0000c0de/0x12345678 -> B2A 0x0000C0DE/0x12345678 e=5 OK
sent 0x0000face/0xdeadbeef -> B2A 0x0000FACE/0xDEADBEEF e=6 OK
sent 0x00001234/0xcafef00d -> B2A 0x00001234/0xCAFEF00D e=7 OK
sent 0x0000aa55/0x55aa55aa -> B2A 0x0000AA55/0x55AA55AA e=8 OK
```
Echo counter increments 1:1 with sends; both CMD and DAT echo back verbatim.
@@ -1,7 +1,8 @@
# HPMCU mailbox-echo firmware — bare-metal RV32IMC for the RV1106 SCR1 core.
# Same xPack riscv-none-embed-gcc 10.2.0 + flags as the watchdog firmware.
XPACK ?= <flare-edge>/sdk/sysdrv/source/mcu/prebuilts/gcc/linux-x86/riscv64/xpack-riscv-none-embed-gcc-10.2.0-1.2/bin
# Point XPACK at the xpack riscv toolchain bin/ inside a flare-edge SDK checkout.
XPACK ?= $(error set XPACK to the xpack-riscv-none-embed-gcc bin/ directory)
CROSS ?= $(XPACK)/riscv-none-embed-
CC = $(CROSS)gcc
@@ -197,7 +197,8 @@ the whole problem.
`PROVENANCE.md`: the kernel driver is portable GPL; the closed piece is the
userspace RKNN runtime + regcmd format (a blob). **Per directive we do not ship
that blob.** `CAPABILITIES-AUDIT.md:32` rates NPU "not worth shipping" until an
that blob.** `CAPABILITIES-AUDIT.md`'s Remaining-blocks table rates the NPU
"open driver VERIFIED; compute deferred" until an
open encoder exists.
### URLs
@@ -299,5 +300,5 @@ ever begun; otherwise this is the documented reason open NPU compute is deferred
---
_Cross-refs: `PORT-PLAN.md` (authoritative file-by-file kernel port),
`../../docs/npu-graphics-feasibility.md`, `../CAPABILITIES-AUDIT.md:32`,
`../../docs/npu-graphics-feasibility.md`, `../CAPABILITIES-AUDIT.md`,
`../PROVENANCE.md`, `../DRIVER-PARITY.md:41`, `../REMAINING-PORTS.md §6`._
+1 -1
View File
@@ -8,7 +8,7 @@ target as the rest of this port: **Linux 6.18.46 vanilla**
(`flare-edge/research/linux-6.18.46/`), forward-ported from vendor 5.10.160, built
with our `arm-rockchip830-...-gcc 8.3` toolchain — see `../PORT-STATUS.md` and
`../../docs/bringup.md` for the method and milestones this slots into (M6, listed
in `../DRIVER-PARITY.md` as "NPU (rknpu, ff660000) | out-of-tree | M6").
in `../DRIVER-PARITY.md` as "NPU (rknpu, ff660000) | out-of-tree | [ ] M6").
Builds on `warden-sdk/docs/npu-graphics-feasibility.md`, which already read this
same driver source to answer a narrower question (can the NPU do graphics — no).
@@ -331,6 +331,6 @@ the value of checking the decoded string, not just the ioctl return code.
## Also updated this session
`../DRIVER-PARITY.md`'s NPU row: ` M6 — plan: npu/PORT-PLAN.md` -> `🔨 M6 built,
`../DRIVER-PARITY.md`'s NPU row: `[ ] M6 — plan: npu/PORT-PLAN.md` -> `[wip] M6 built,
0 errors/0 warnings, 99 rknpu-prefixed symbols in System.map, &npu
{status="okay"} in the dtb — not yet flashed/probed on hardware`.
+1 -1
View File
@@ -1,4 +1,4 @@
# NPU (rknpu) open kernel driver — VERIFIED on warden-c8a3 (2026-08-25)
# NPU (rknpu) open kernel driver — VERIFIED on warden-c8a3 (2026-08-25)
The open GPL rknpu kernel driver runs on our self-built Linux 6.18.46. This is the
achievable open end state (Tier A in `OPEN-NPU-PLAN.md`); open *compute* remains a
+1 -1
View File
@@ -1,4 +1,4 @@
# PVTM (Process-Voltage-Temperature Monitor) — VERIFIED on warden-c8a3 (2026-08-25)
# PVTM (Process-Voltage-Temperature Monitor) — VERIFIED on warden-c8a3 (2026-08-25)
Whole-driver port: mainline 6.18 has **no** rockchip pvtm driver; the vendor
`drivers/soc/rockchip/rockchip_pvtm.c` (GPL-2.0, 1046L) supports rv1106. Copied it in
+4 -3
View File
@@ -284,7 +284,7 @@ implied by this kernel port.
reports true (`querystring(RGA_VERSION)` succeeds — `warden_rga.c:458-466`) and the
Monitor page's "RGA" load metric moves during graph scroll (`warden_rga_load_pct()`,
`warden_rga.c:226-241`) — the same on-target evidence bar as every other change in
this repo (`../../CLAUDE.md`: "UI/daemon changes are verified on a real panel").
this repo (repo policy: "UI/daemon changes are verified on a real panel").
## Summary of residual risk (PORT-VERIFY-class items)
@@ -294,8 +294,9 @@ implied by this kernel port.
- **CMA pool sizing** — 10 MiB was sized against the 5.10 image's actual usage (graph
canvas + scanout mirror at 720×720×4B ≈ 2 MiB each); carry the same size unless a
future accounting shows it's tight.
- **Driver-parity table** (`../DRIVER-PARITY.md`) should move `RGA 2D (rga2)` from to
🔨/✅ as these steps land, same convention as every other M-milestone row.
- **Driver-parity table** (`../DRIVER-PARITY.md`) should move `RGA 2D (rga2)` from [ ] to
[wip]/[x] as these steps land, same convention as every other M-milestone row
(the parity row already reads [x]).
## Sources
@@ -1,6 +1,6 @@
# TRNG + OTP port (batch A) — 6.18
## TRNG (hardware RNG) — VERIFIED on warden-c8a3 (2026-08-25)
## TRNG (hardware RNG) — VERIFIED on warden-c8a3 (2026-08-25)
rv1106's `rockchip,trngv1` is the same standalone TRNG_V1 IP as rk3588 (identical
register map). Mainline `drivers/char/hw_random/rockchip-rng.c` already drives it.
@@ -21,7 +21,7 @@ rv1106's clock/reset names need no special handling.) Kconfig
`dd if=/dev/hwrng bs=16``c697 503d f9db 6b84 50e4 e1ee f232 b2ae` (real HW
entropy, non-zero). Hardware entropy source for the panel's crypto/keys.
## OTP / nvmem — VERIFIED on warden-c8a3 (2026-08-25)
## OTP / nvmem — VERIFIED on warden-c8a3 (2026-08-25)
Reads real data: `dd .../rockchip-otp0/nvmem bs=1 count=16 | xxd`
`5211 02fe 084d 5231 0000 0000 3b15 0000` (contains "MR1" chip id) — no timeout.
Mainline `drivers/nvmem/rockchip-otp.c` gains an `rv1106_data` + compatible.
+2 -2
View File
@@ -1,7 +1,7 @@
# GT911 capacitive touch — VERIFIED on warden-c8a3 (2026-08-25)
# GT911 capacitive touch — VERIFIED on warden-c8a3 (2026-08-25)
Touch works on our self-built 6.18.46 (`_b`): the WardenOS UI responds to
taps/swipes ([maintainer] confirmed on the physical panel). Objective evidence:
taps/swipes (confirmed by hand on the physical panel). Objective evidence:
```
Goodix-TS 3-0014: ID 911, version: 1060
+4 -4
View File
@@ -120,7 +120,7 @@ cfg80211.ko → libarc4.ko → ctr.ko → ccm.ko → libaes.ko → aes_generic.k
see `insmod_wifi.sh:126-137` and `wifi-bluetooth-aic8800.md`); nothing to
replicate there. The crypto modules (arc4/ctr/ccm/aes) are dependencies of the
driver's internal key-handling, not aic8800-specific — confirm they're already
`=y`/reachable in the 6.18 config (crypto is currently listed as " batch2" in
`=y`/reachable in the 6.18 config (crypto is currently listed as "[ ] batch2" in
`../DRIVER-PARITY.md`; flip alongside this work).
**DT node — correction to the task's framing.** The task description assumed
@@ -295,7 +295,7 @@ No breaking changes found in `sdio_driver`, `sdio_claim_host`/`release_host`,
`sdio_readb`/`writesb`, `sdio_set_block_size` between 5.10 and 6.18 (low
research depth on this axis — treat as low-risk, smoke-test rather than
line-audit). The mainline `dw_mmc`/`dw_mmc-rockchip` host driver is already
proven on 6.18 for eMMC (`../DRIVER-PARITY.md`: `dw_mmc (eMMC) | mainline | M3`)
proven on 6.18 for eMMC (`../DRIVER-PARITY.md`: `dw_mmc (eMMC) | mainline | [x] M3`)
and `CONFIG_MMC_DW_ROCKCHIP=y` is already in the live `.config` — the SDIO
*controller* side of this port is de-risked; only the AIC8800 *card driver*
above the `sdmmc` bus is new work.
@@ -410,7 +410,7 @@ CONFIG_BT_HCIUART_H4=y # already =y (H4 is the transport this board's BT a
# per hardware-86-panel.md: UART1/ttyS1, hciattach -s 1500000
# ... any 1500000 flow nosleep)
CONFIG_CRYPTO_ARC4=y CONFIG_CRYPTO_CTR=y CONFIG_CRYPTO_CCM=y CONFIG_CRYPTO_AES=y # driver's
# internal key-handling deps, currently " batch2" in
# internal key-handling deps, currently "[ ] batch2" in
# ../DRIVER-PARITY.md — confirm =y, not =m, alongside this work
```
Do **not** enable `CONFIG_MAC80211` for this driver — confirmed by source
@@ -537,7 +537,7 @@ Follow the existing A/B `_b`-slot hardware-verification loop
attaches and `hciattach -s 1500000 /dev/ttyS1 any 1500000 flow nosleep`
(the known-good invocation) brings up an HCI device.
8. **Update on landing**: mark the `AIC8800 wifi (bsp/fdrv)` and
`AIC8800 BT (btlpm)` rows in `../DRIVER-PARITY.md` , with the same
`AIC8800 BT (btlpm)` rows in `../DRIVER-PARITY.md` [x], with the same
"hardware-verified, not just compiled" bar every other row uses.
## Sources
@@ -10,10 +10,10 @@ live RF scan.
`Start app: 00120000`, BSP_RC=0.
- `insmod aic8800_fdrv.ko``ieee80211 phy0: HT supp 1, VHT supp 1, HE supp 1`,
FDRV_RC=0.
- `wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... link/ether [device-mac]`
- `iw dev wlan0 scan` found real APs:
- **the site AP [bssid] 2412 MHz 43 dBm**
- a neighboring guest AP [bssid] 2412 MHz 73 dBm
- `wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> ... link/ether <device MAC>`
- `iw dev wlan0 scan` found real APs (SSIDs/BSSIDs redacted for publication):
- **the site AP at 2412 MHz, 43 dBm**
- a neighboring guest AP at 2412 MHz, 73 dBm
- +several more, correct signal strengths → RF path fully functional.
## Why MODULES, not built-in (=y)
+7
View File
@@ -27,6 +27,13 @@ with `../build/warden_defconfig` → `zImage` + `rv1106-warden.dtb`).
| `70-audio-codec.patch` | `rv1106_codec` + I2S wiring |
| `80-misc-thermal-rtc-adc.patch` | tsadc, rtc-rockchip, saradc, trng, otp, gmac, goodix touch |
## License
Everything in this directory is a derivative work of the Linux kernel and of
GPL-2.0 vendor kernel code: **GPL-2.0-only** (or the per-file SPDX identifier
where one is present), the same license the whole repository carries. Per-driver
origin and license are tracked in `kernel/rv1106-enablement/PROVENANCE.md`.
## Provenance & regeneration
Baseline: `linux-6.18.46` from kernel.org (`build/linux-6.18.46.tar.xz.sha256`
+102
View File
@@ -0,0 +1,102 @@
# qemu/ — the WardenOS device simulator
A QEMU virtual machine that boots the real forward-ported kernel (`build/` +
`patches/`) and real userspace, so the *device* — init, daemons, networking,
OTA, watchdog, display — can be tested off-hardware. The third simulator in
the stack, deliberately not named "sim":
- `lvglsim` (flare-edge) — SDL desktop build of the UI. Rendering only.
- `sim/` (this repo) — register-level Rust models of RV1106 blocks behind
driver seams.
- `qemu/` (this) — the whole machine above the kernel entry point, running
the real binaries.
Decision record: `docs/decisions/0006-qemu-device-sim.md`.
## The boundary (read this before trusting a green run)
There is no RV1106 machine model in QEMU and everything below the kernel is
closed rkbin blobs plus mask ROM, so the VM **enters at `-kernel zImage`** on
`-M virt,highmem=off` (single Cortex-A7, 256M — the RV1106G3's shape).
| Emulated / substituted | Not emulated (stays bench / `sim/` territory) |
|---|---|
| Kernel boot, init ordering, switch_root | BootROM, idblock/DDR-init, SPL, U-Boot |
| A/B *outcome* (`warden.slot=` cmdline) | Real BCB A/B selection, bootcount auto-revert |
| Storage: virtio-blk with the device's exact `blkdevparts=` layout + `/dev/block/by-name/` contract | eMMC controller itself |
| Network: virtio-net (slirp, hostfwd 22/80/28443) | GMAC, AIC8800 wifi, usb0 gadget |
| Display: virtio-gpu 720x720 via fbdev emulation | VOP/RGB666 pipeline, CH32V003 panel init, RGA blits |
| Touch: virtio-tablet (QMP `input-send-event`) | GT911 on I2C3 |
| Watchdog: i6300esb (PCI), `-action watchdog=reset` | DW watchdog @0xff5a0000, HPMCU supervisor |
| RS485: pci-serial chardev bridged to `sim/`'s `ModbusSlave` | Real UART4 timing/electrical behavior |
| RTC: PL031 (`--rtc` reproduces the no-RTC 2021-clock incident class) | The unpopulated backup-cell reality |
**"Boots/works under emulation" is never evidence of "works on silicon."**
The VM narrows which claims need a panel; on-device claims still need
on-device evidence. Conversely, the VM is the first environment that runs
production binaries on a non-RV1106 memory map — it found flare-edge #106
(fatal SIGBUS in flared's HPMCU probe) and #107 (Y2038 time_t truncation)
on its first two boots of real userspace.
Documented guest deviations from production, set by stage-2 init:
`WARDEN_FLARE_INSECURE=1` (the desk mock portal is plain HTTP) and
`WARDEN_HPMCU=0` (no mailbox SRAM on virt; flared >= flare-edge#106 fix
required, or the daemon dies of SIGBUS).
## Quick start
```sh
# 1. kernel: canonical build boots the VM as-is; the fragment variant adds
# the scenario devices (PCI serial, watchdog, WireGuard, virtio-gpu/input)
WORK=$HOME/kbuild-out CROSS_COMPILE=arm-linux-gnueabihf- \
WARDEN_KCONFIG_FRAGMENT=qemu/configs/virt.fragment bash build/build-kernel.sh
# 2. initramfs (sha256-pinned static busybox + qemu/rootfs/) and A/B disk
bash qemu/mkinitramfs.sh
bash qemu/mkimage.sh # options: --portal-url --state K=V --fw-version
# 3. run (see run.sh header for all flags)
bash qemu/run.sh --kernel $HOME/kbuild-out/linux-6.18.46/arch/arm/boot/zImage --shell
```
Payload: drop static musl armv7 binaries into `qemu/payload/` (see its
README) — `warden-flared`, `warden-modbus`, and `warden-ui` (the LVGL
fbdev+evdev build from flare-edge `tools/build-ui-vm.sh`) are started by
stage-2 init when present.
## Scenario tests (`qemu/tests/`)
- `boot-smoke.sh <zImage>` — sentinel-asserting boot; runs in CI inside the
kernel-build job.
- `portal-scenario.sh <zImage>` (needs `FLARE_EDGE=<checkout>`) — the real
flared in the VM against the desk mock portal: authenticated check-in,
firmware desired-state pull, and download of a real signed tier-1 `.wfw`
offer. Verify/stage/APPLYING run as a dry run (no `WARDEN_FW_ALLOW_APPLY`);
flipping it on inside the VM is the documented stretch — apply writes
`/dev/block/by-name/rootfs_b` inside disk.img, then `--slot _b` boots it.
- `ui-shot.sh <zImage>` — display+touch: boots headless with virtio-gpu,
QMP-screendumps the 720x720 UI, taps the Metrics tab via `input-send-event`
(a 200 ms hold — an instantaneous press+release lands inside one LVGL poll
and never clicks), and asserts the frame changed. `qmp.py` is the tiny QMP
client.
- Watchdog: `run.sh --watchdog`, arm `/dev/watchdog` in the guest, don't pet —
the VM resets ~30 s later (verified). Do NOT combine with a flared payload
expecting survival: flared pets only while the UI heartbeat is fresh.
## Gotchas that cost time (so they cost it once)
- AF_UNIX socket paths cap at ~108 chars — keep `--rs485`/`--qmp` paths short.
- A serial port that is closed discards incoming bytes: hold ONE fd open
across write and read when scripting the guest side of the RS485 bridge.
- `highmem=off` and `-global virtio-mmio.force-legacy=false` are load-bearing
(32-bit ECAM reach; virtio-1-only gpu/input) — both live ONLY in run.sh,
which every script (boot smoke included) delegates to.
- Never pass `earlyprintk`: DEBUG_UART_PHYS is the RV1106's 0xff4c0000.
## Host requirements
`qemu-system-arm` (Debian 13 ships QEMU 10), `curl`, `cpio`, `mkfs.ext4`,
`gcc-arm-linux-gnueabihf` (kernel build), `python3` (+`cryptography` for the
portal scenario's `.wfw` signing). CI: the hosted `qemu-tools` job builds the
tooling; the boot smoke runs inside the (also hosted, dispatch-only)
`kernel-build` job, which apt-installs its own toolchain and qemu (ADR-0007).
+10
View File
@@ -0,0 +1,10 @@
# The device's canonical 12-partition A/B layout, expressed for the VM's
# virtio disk (vda). On hardware the same string names mmcblk0 and is baked
# into the U-Boot env — source: flare-edge docs/decisions/0003-partition-layout.md.
# There is no MBR/GPT anywhere: U-Boot and Linux both parse this string, which
# is why handing it to the VM kernel on the cmdline reproduces the exact
# partition map (vda9 = rootfs_a = hardware mmcblk0p9).
#
# Sourced by qemu/mkimage.sh (computes byte offsets from it) and qemu/run.sh
# (passes it verbatim in -append). Single source of truth — edit only here.
WARDEN_BLKDEVPARTS='vda:32K(env),512K@32K(idblock),512K(uboot),512K(misc),32M(boot_a),32M(boot_b),128M(oem_a),128M(oem_b),1G(rootfs_a),1G(rootfs_b),32M(recovery),1G(userdata)'
+1
View File
@@ -0,0 +1 @@
cd04052b8b6885f75f50b2a280bfcbf849d8710c8e61d369c533acf307eda064
+36
View File
@@ -0,0 +1,36 @@
# QEMU -M virt kernel variant — merged onto build/warden_defconfig via
# WARDEN_KCONFIG_FRAGMENT (see build/build-kernel.sh). The RV1106 zImage stays
# canonical and byte-identical when the variable is unset.
#
# The canonical zImage already BOOTS on -M virt as-is (verified 2026-08-29:
# the multi_v7 heritage supplies ARCH_VIRT + the virtio set, and DEBUG_LL's
# hardcoded RV1106 UART is inert as long as `earlyprintk` is never passed on
# the cmdline). This fragment therefore only ADDS what the device-sim
# scenarios need beyond the canonical config.
# -M virt exposes exactly one PL011 (probed: QEMU 10 dtb has a single
# pl011@9000000); every additional device below rides the machine's PCIe
# (ECAM "host generic") root.
CONFIG_PCI=y
CONFIG_PCI_HOST_GENERIC=y
# Second UART for the RS485/Modbus bridge: -device pci-serial (16550-class,
# shows up as ttyS0; stage-2 init aliases it to the device's /dev/ttyS4).
CONFIG_SERIAL_8250_PCI=y
# /dev/watchdog for flared's watchdog_loop() — untestable on both existing
# sims. i6300esb is the watchdog QEMU offers on arm virt (PCI device):
# -device i6300esb -action watchdog=reset.
CONFIG_WATCHDOG=y
CONFIG_I6300ESB_WDT=y
# wg0 mesh scenarios (flared owns identity, the panel shells out `wg`).
CONFIG_WIREGUARD=y
# Display + touch: virtio-gpu scanout with fbdev emulation (the VM UI build
# uses LVGL's fbdev backend — no libdrm needed in the guest), virtio-tablet
# for absolute-coordinate touch injection via QMP.
CONFIG_FB=y
CONFIG_DRM_VIRTIO_GPU=y
CONFIG_DRM_FBDEV_EMULATION=y
CONFIG_VIRTIO_INPUT=y
+81
View File
@@ -0,0 +1,81 @@
# shellcheck shell=bash
# Shared helpers for the qemu/ device-sim build scripts. Sourced, not executed.
# Callers must run under `set -euo pipefail` and define QEMU_DIR (the qemu/ dir).
BB_VER=1.31.0
BB_URL="https://busybox.net/downloads/binaries/${BB_VER}-defconfig-multiarch-musl/busybox-armv7l"
qemu_log() { printf '\033[36m== %s\033[0m\n' "$*"; }
# Fetch (or accept via $BUSYBOX) the pinned static armv7 busybox and verify it
# against qemu/busybox.sha256. FAILS CLOSED: a missing pin refuses to build,
# never silently skips verification — mirroring build/build-kernel.sh's
# tarball handling. Sets $BB to the verified binary's path.
qemu_get_busybox() {
local sha_file="$QEMU_DIR/busybox.sha256"
local out="${OUT:-$QEMU_DIR/out}"
mkdir -p "$out"
BB="${BUSYBOX:-$out/busybox-armv7l}"
# Pin first: a missing pin refuses BEFORE downloading, same ordering as
# build/fetch-kernel-tarball.sh.
[ -f "$sha_file" ] || {
echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2
exit 1
}
if [ ! -f "$BB" ]; then
qemu_log "downloading $BB_URL"
curl --retry 3 --retry-delay 5 --retry-connrefused -fSL "$BB_URL" -o "$BB"
fi
local want got
want="$(cat "$sha_file")"
got="$(sha256sum "$BB" | awk '{print $1}')"
[ "$want" = "$got" ] || { echo "busybox sha256 mismatch: want $want got $got" >&2; exit 1; }
qemu_log "busybox sha256 verified"
}
# Stage the shared rootfs skeleton (qemu/rootfs/ + busybox) into $1.
# Requires qemu_get_busybox to have run (uses $BB).
qemu_stage_rootfs() {
local root="$1"
mkdir -p "$root/bin" "$root/sbin" "$root/dev" "$root/proc" "$root/sys" \
"$root/etc" "$root/tmp" "$root/mnt" "$root/userdata" "$root/oem" \
"$root/usr/bin" "$root/usr/share/udhcpc"
install -m 0755 "$BB" "$root/bin/busybox"
cp -a "$QEMU_DIR/rootfs/." "$root/"
chmod 0755 "$root/init" "$root/sbin/init" "$root/etc/rc" \
"$root/usr/share/udhcpc/default.script"
}
# Parse a "SIZE[@OFFSET](NAME)" blkdevparts entry list (without the "vda:"
# prefix) and invoke a callback `$1 name offset_bytes size_bytes` per entry.
qemu_each_partition() {
local cb="$1" parts entry size_s off_s name size off
parts="${WARDEN_BLKDEVPARTS#*:}"
off=0
local IFS=','
for entry in $parts; do
name="${entry##*(}"; name="${name%)}"
size_s="${entry%%(*}"
if [ "${size_s#*@}" != "$size_s" ]; then
off_s="${size_s#*@}"; size_s="${size_s%@*}"
off="$(qemu_to_bytes "$off_s")"
fi
size="$(qemu_to_bytes "$size_s")"
"$cb" "$name" "$off" "$size"
off=$((off + size))
done
}
qemu_to_bytes() {
local v="$1" n mult=1
case "$v" in
*K) n="${v%K}"; mult=1024 ;;
*M) n="${v%M}"; mult=1048576 ;;
*G) n="${v%G}"; mult=1073741824 ;;
*) n="$v" ;;
esac
case "$n" in
''|*[!0-9]*) echo "FATAL: bad blkdevparts size/offset token: '$v'" >&2; exit 1 ;;
esac
echo $((n * mult))
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Build the VM's virtio disk image carrying the device's canonical 12-partition
# A/B layout (qemu/blkdevparts.conf — the same string U-Boot and Linux parse on
# hardware; there is no MBR/GPT). Every partition is placed at the exact offset
# the cmdline string declares; rootfs_a/rootfs_b/oem_a/oem_b/userdata get ext4,
# the boot-chain partitions (env/idblock/uboot/misc/boot_a/boot_b/recovery)
# stay zeroed — the VM enters at -kernel and never reads them.
#
# Built entirely UNPRIVILEGED: per-partition mkfs.ext4 -d (no loop mounts, no
# sudo), then dd'd into a sparse raw image.
#
# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V]
# Env:
# BUSYBOX path to a local busybox binary (skips the download; still verified)
# OUT output dir (default: qemu/out); image at $OUT/disk.img
set -euo pipefail
QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=qemu/lib.sh disable=SC1091
. "$QEMU_DIR/lib.sh"
# shellcheck source=qemu/blkdevparts.conf disable=SC1091
. "$QEMU_DIR/blkdevparts.conf"
OUT="${OUT:-$QEMU_DIR/out}"
# mkfs.ext4 lives in sbin, which user shells on Debian don't have on PATH.
PATH="$PATH:/usr/sbin:/sbin"
PORTAL_URL=""
STATE_KV=()
FW_VERSION="0.0.1"
while [ $# -gt 0 ]; do
case "$1" in
--portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;;
--state)
case "${2:?--state needs KEY=VALUE}" in
*=*) ;;
*) echo "FATAL: --state needs KEY=VALUE, got '$2'" >&2; exit 1 ;;
esac
case "${2%%=*}" in
.|..)
echo "FATAL: --state key cannot be '.' or '..'" >&2
exit 1 ;;
*[!A-Za-z0-9_.]*|'')
echo "FATAL: --state key '${2%%=*}' must match [A-Za-z0-9_.]+ (it becomes a filename)" >&2
exit 1 ;;
esac
STATE_KV+=("$2"); shift 2 ;;
--fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;;
*) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;;
esac
done
qemu_get_busybox
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-image.XXXXXX")"
trap 'rm -rf "$SCRATCH"' EXIT
# Stage the rootfs tree once (skeleton + payload into /usr/bin), reused for
# both slots so A and B start byte-identical, like a factory flash.
ROOT="$SCRATCH/root"
qemu_stage_rootfs "$ROOT"
for p in "$QEMU_DIR"/payload/*; do
[ -f "$p" ] || continue
case "$(basename "$p")" in README.md) continue ;; esac
install -m 0755 "$p" "$ROOT/usr/bin/$(basename "$p")"
done
# Firmware version stamp — same path the device build writes; flared reads its
# running version here (downgrade rules key off it).
printf '%s\n' "$FW_VERSION" > "$ROOT/etc/warden-firmware-version"
# Seed persistent state (flared: one file per key under /userdata/warden).
# Newline-terminated, matching how flare-edge's fw-e2e-test.sh seeds the store.
UDATA="$SCRATCH/userdata"
mkdir -p "$UDATA/warden"
[ -n "$PORTAL_URL" ] && printf '%s\n' "$PORTAL_URL" > "$UDATA/warden/flare.url"
for kv in ${STATE_KV[@]+"${STATE_KV[@]}"}; do
printf '%s\n' "${kv#*=}" > "$UDATA/warden/${kv%%=*}"
done
mkdir -p "$SCRATCH/empty"
# mkfs an ext4 partition image of exactly $2 bytes from staged dir $1.
mkfs_part() {
local stage="$1" bytes="$2" img="$3"
rm -f "$img"
truncate -s "$bytes" "$img"
mkfs.ext4 -F -q -d "$stage" "$img"
}
DISK="$OUT/disk.img"
rm -f "$DISK"
place_partition() {
local name="$1" off="$2" size="$3" stage=""
case "$name" in
rootfs_a|rootfs_b) stage="$ROOT" ;;
userdata) stage="$UDATA" ;;
oem_a|oem_b) stage="$SCRATCH/empty" ;;
# Boot-chain partitions the VM never reads: present at the right offsets,
# left zeroed. Enumerated (not a wildcard) so a typo'd name in
# blkdevparts.conf fails HERE, not as a confusing mount error at boot.
env|idblock|uboot|misc|boot_a|boot_b|recovery) stage="" ;;
*) echo "FATAL: unknown partition name '$name' in blkdevparts.conf" >&2; exit 1 ;;
esac
# dd in 4K blocks — every offset in the canonical layout is 4K-aligned;
# assert rather than assume, a misaligned write would corrupt a neighbor.
if [ $((off % 4096)) -ne 0 ] || [ $((size % 4096)) -ne 0 ]; then
echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2
exit 1
fi
# Max, not last: blkdevparts grammar permits explicit @offsets out of order.
[ $((off + size)) -gt "$DISK_END" ] && DISK_END=$((off + size))
[ -z "$stage" ] && return 0
local img="$SCRATCH/$name.img"
mkfs_part "$stage" "$size" "$img"
dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \
conv=notrunc,sparse status=none
qemu_log " $name: ext4, $((size / 1048576))M @ $off"
}
DISK_END=0
qemu_log "building $DISK ($WARDEN_BLKDEVPARTS)"
truncate -s 0 "$DISK"
qemu_each_partition place_partition
truncate -s "$DISK_END" "$DISK"
qemu_log "disk image: $DISK ($(du -h "$DISK" | cut -f1) used, $((DISK_END / 1048576))M apparent)"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Build the QEMU device-sim initramfs: the pinned static busybox + qemu/rootfs/.
# The busybox binary is the ONLY external input (sha256-pinned, fail-closed —
# see qemu/lib.sh).
#
# Env:
# BUSYBOX path to a local busybox binary (skips the download; still verified)
# OUT output dir (default: qemu/out); initramfs at $OUT/initramfs.cpio.gz
set -euo pipefail
QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=qemu/lib.sh disable=SC1091
. "$QEMU_DIR/lib.sh"
OUT="${OUT:-$QEMU_DIR/out}"
qemu_get_busybox
# Assemble in a scratch dir, removed on exit (scratch-dir leaks were a past
# review finding in this repo).
SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-initramfs.XXXXXX")"
trap 'rm -rf "$SCRATCH"' EXIT
qemu_stage_rootfs "$SCRATCH/root"
# Pack (newc cpio, gzip). No device nodes required: /init mounts devtmpfs and
# reopens the console itself, so the archive builds unprivileged.
( cd "$SCRATCH/root" && find . -print0 | cpio -0 -o -H newc -R +0:+0 2>/dev/null ) \
| gzip -9 > "$OUT/initramfs.cpio.gz"
qemu_log "initramfs: $OUT/initramfs.cpio.gz ($(du -h "$OUT/initramfs.cpio.gz" | cut -f1))"
+25
View File
@@ -0,0 +1,25 @@
# qemu/payload/ — guest binaries (never committed)
Drop **static musl armv7** binaries here; `qemu/mkimage.sh` copies everything
in this directory (except this README) into `/usr/bin/` of both rootfs slots.
Static musl is the same target the device uses for its Rust daemons, so the
exact production binaries run unmodified in the VM.
Typical payload, built in a flare-edge checkout:
```sh
# flared (static musl armv7)
tools/build-flared.sh --local
# warden-modbus and friends: see tools/build-firmware.sh for the recipes
```
Then:
```sh
cp <flare-edge>/target/armv7-unknown-linux-musleabihf/release/warden-flared qemu/payload/
```
Stage-2 init starts `warden-flared`, `warden-modbus`, and `warden-ui` (the
UI additionally needs `--display on|headless` + the virt.fragment kernel for
/dev/fb0) automatically when present (logs land in `/tmp/<name>.log` inside
the guest). An empty payload is valid — the image boots busybox-only.
+43
View File
@@ -0,0 +1,43 @@
#!/bin/busybox sh
# shellcheck shell=dash
# Stage-1 rc: sourced by /init (still PID 1, initramfs root) when a virtio
# disk is present. Emulates U-Boot's slot choice — mount the validated slot's
# rootfs and switch_root into it. This is an EMULATION of the A/B selection
# outcome, not the BCB/bootcount mechanism itself.
#
# Every guarded failure path `return`s to /init (valid in a sourced script;
# /init then falls through to shell/poweroff). The final exec is the one
# unguardable step: if switch_root itself fails to launch, the shell — PID 1 —
# exits and the kernel panics; the applet-existence check below catches the
# only preventable variant of that.
# shellcheck source=qemu/rootfs/etc/warden-lib.sh disable=SC1091
. /etc/warden-lib.sh
warden_populate_by_name
slot="$(warden_slot)"
root="/dev/block/by-name/rootfs${slot}"
if [ ! -e "$root" ]; then
echo "rc: $root missing — staying in initramfs"
return 0
fi
mkdir -p /mnt
if ! mount -t ext4 "$root" /mnt; then
echo "rc: mount of $root failed — staying in initramfs"
return 0
fi
if [ ! -x /mnt/sbin/init ]; then
echo "rc: $root has no /sbin/init — staying in initramfs"
umount /mnt
return 0
fi
if ! command -v switch_root >/dev/null; then
echo "rc: busybox lacks switch_root — staying in initramfs"
umount /mnt
return 0
fi
echo "rc: switching root to rootfs${slot} ($root)"
exec switch_root /mnt /sbin/init
+43
View File
@@ -0,0 +1,43 @@
# shellcheck shell=sh
# Shared helpers for the VM's stage-1 (/init + /etc/rc, initramfs) and stage-2
# (/sbin/init, disk rootfs) boot scripts. Present in both filesystems because
# both are staged from the same qemu/rootfs/ skeleton. ONE copy of each rule —
# the slot-validation drift between two hand-copied parsers was a real
# review finding.
# Populate /dev/block/by-name/<PARTNAME> symlinks from sysfs uevents — the
# contract flare-edge's slotctl.rs relies on. blkdevparts= gives every vda
# partition a PARTNAME.
warden_populate_by_name() {
mkdir -p /dev/block/by-name
for uev in /sys/class/block/vda*/uevent; do
[ -f "$uev" ] || continue
partname=""
devname=""
while IFS='=' read -r k v; do
case "$k" in
PARTNAME) partname="$v" ;;
DEVNAME) devname="$v" ;;
esac
done < "$uev"
[ -n "$partname" ] && [ -n "$devname" ] \
&& ln -sf "/dev/$devname" "/dev/block/by-name/$partname"
done
}
# Parse warden.slot= from the cmdline (whole-token, never substring) and
# VALIDATE it — echoes "_a" or "_b", falling back to _a with a warning.
warden_slot() {
slot="_a"
# shellcheck disable=SC2013 # cmdline TOKENS are the unit here, not lines
for tok in $(cat /proc/cmdline); do
case "$tok" in
warden.slot=*) slot="${tok#warden.slot=}" ;;
esac
done
case "$slot" in
_a|_b) ;;
*) echo "warden-lib: bad warden.slot='$slot', falling back to _a" >&2; slot="_a" ;;
esac
echo "$slot"
}
+34
View File
@@ -0,0 +1,34 @@
#!/bin/busybox sh
# shellcheck shell=dash
# WardenOS QEMU device sim: initramfs /init (PID 1).
#
# Phase-1 duty: prove the kernel booted on -M virt — print the sentinel the
# smoke test greps for, then power off (PSCI SYSTEM_OFF, so qemu exits).
# `warden.shell` on the kernel cmdline drops to an interactive shell instead.
/bin/busybox mount -t devtmpfs devtmpfs /dev 2>/dev/null
# The cpio archive carries no device nodes (it is built unprivileged, no
# mknod); reopen stdio on the real console now that devtmpfs is mounted.
exec </dev/console >/dev/console 2>&1
/bin/busybox --install -s /bin
mount -t proc proc /proc
mount -t sysfs sysfs /sys
echo "WARDEN-QEMU-BOOT-OK"
# With a virtio disk attached, hand over to the stage-1 rc (by-name symlinks,
# slot select, switch_root). It only returns on failure — then fall through to
# the diskless shell/poweroff behavior below.
if [ -b /dev/vda ]; then
# shellcheck source=qemu/rootfs/etc/rc disable=SC1091
. /etc/rc
fi
if grep -qw warden.shell /proc/cmdline; then
echo "warden.shell: interactive shell (exit to power off)"
setsid cttyhack sh
fi
poweroff -f
+96
View File
@@ -0,0 +1,96 @@
#!/bin/busybox sh
# shellcheck shell=dash
# Stage-2 init: PID 1 on the disk rootfs (rootfs_a or rootfs_b), reached via
# switch_root from the initramfs. Brings up the minimum a WardenOS userspace
# needs — mounts, by-name symlinks, network, serial alias — then starts any
# payload daemons and holds. This stands in for the device's BusyBox SysV
# /etc/init.d/S* sequence; it is deliberately tiny, not a model of it.
/bin/busybox mount -t devtmpfs devtmpfs /dev 2>/dev/null
exec </dev/console >/dev/console 2>&1
/bin/busybox --install -s /bin
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t tmpfs tmpfs /tmp
# shellcheck source=qemu/rootfs/etc/warden-lib.sh disable=SC1091
. /etc/warden-lib.sh
# Fresh devtmpfs — repopulate the by-name contract; same VALIDATED slot rule
# as stage 1 (shared helper, so the two can never drift).
warden_populate_by_name
slot="$(warden_slot)"
# The device's matched mounts: persistent state and the slot's oem partition.
# Fail-fast: a scenario against an image whose userdata cannot mount would
# otherwise burn its whole deadline before failing generically. warden.shell
# still gets a shell for post-mortem.
mount_fatal() {
if ! mount -t ext4 "/dev/block/by-name/$1" "$2"; then
echo "WARDEN-QEMU-MOUNT-FAILED $1"
if grep -qw warden.shell /proc/cmdline; then
echo "warden.shell: post-mortem shell (exit powers off)"
setsid cttyhack sh
fi
poweroff -f
fi
}
mount_fatal userdata /userdata
mount_fatal "oem${slot}" /oem
mkdir -p /userdata/warden
# RS485: warden-modbus hardcodes /dev/ttyS4 at compile time; alias it to the
# VM's pci-serial UART when one is present (needs the virt.fragment kernel).
[ -c /dev/ttyS0 ] && ln -sf /dev/ttyS0 /dev/ttyS4
# Network: slirp user-mode net on eth0 (DHCP, fallback to QEMU's static map).
# The fallback keys off the interface actually having an address — udhcpc
# exiting 0 only proves a lease, not that the hook script applied it.
ip link set lo up
if [ -e /sys/class/net/eth0 ]; then
ip link set eth0 up
udhcpc -i eth0 -n -q -t 5 -T 2 >/dev/null 2>&1 || true
if ! ip -4 addr show dev eth0 | grep -q 'inet '; then
ip addr add 10.0.2.15/24 dev eth0 2>/dev/null
ip route replace default via 10.0.2.2 dev eth0
echo "nameserver 10.0.2.3" > /etc/resolv.conf
fi
fi
hostname warden-qemu
echo "WARDEN-QEMU-ROOTFS-OK slot=${slot}"
# Payload daemons (dropped into /usr/bin by qemu/mkimage.sh from qemu/payload/).
# WARDEN_FLARE_INSECURE=1: the VM's portal is the desk mock over plain HTTP.
# This is a dev instrument — a production device build never sets it.
export WARDEN_FLARE_INSECURE=1
# No HPMCU on -M virt: the mailbox SRAM (0xff6fff00) is unmapped bus space
# here, and flared's /dev/mem poke dies with an external abort (SIGBUS). The
# SCR1 supervisor state machine is modeled in sim/src/hpmcu.rs instead.
export WARDEN_HPMCU=0
for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do
if [ -x "$d" ]; then
name="$(basename "$d")"
echo "init: starting $name"
"$d" > "/tmp/${name}.log" 2>&1 &
fi
done
# The UI (LVGL fbdev+evdev build from flare-edge tools/build-ui-vm.sh) needs
# virtio-gpu's fbdev: present only with run.sh --display on|headless AND the
# virt.fragment kernel.
if [ -x /usr/bin/warden-ui ] && [ -c /dev/fb0 ]; then
echo "init: starting warden-ui (fbdev)"
/usr/bin/warden-ui > /tmp/warden-ui.log 2>&1 &
fi
if grep -qw warden.shell /proc/cmdline; then
echo "warden.shell: interactive shell (exit powers off)"
setsid cttyhack sh
poweroff -f
fi
# Hold: daemons run, console idles, scenarios drive the VM from outside.
while :; do sleep 3600; done
+18
View File
@@ -0,0 +1,18 @@
#!/bin/busybox sh
# Minimal udhcpc hook for the VM (busybox looks here by default).
[ -n "$1" ] || exit 1
case "$1" in
deconfig)
ip addr flush dev "$interface"
ip link set "$interface" up
;;
bound|renew)
ip addr replace "$ip/${mask:-24}" dev "$interface"
[ -n "${router:-}" ] && ip route replace default via "${router%% *}" dev "$interface"
if [ -n "${dns:-}" ]; then
: > /etc/resolv.conf
for d in $dns; do echo "nameserver $d" >> /etc/resolv.conf; done
fi
;;
esac
exit 0
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "warden-rs485-bridge"
version = "0.1.0"
dependencies = [
"warden-sim",
]
[[package]]
name = "warden-sim"
version = "0.1.0"
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "warden-rs485-bridge"
version = "0.1.0"
edition = "2021"
description = "Bridges a QEMU serial chardev (unix socket) to the warden-sim Modbus RTU slave, so the VM guest's RS-485 master polls the same simulated field bus the unit tests do — fault injection included."
license = "GPL-2.0-only"
[lib]
name = "warden_rs485_bridge"
path = "src/lib.rs"
[[bin]]
name = "rs485-bridge"
path = "src/main.rs"
# Std-only on purpose: the sim crate it wraps is zero-dep, and unix sockets +
# read timeouts need nothing external.
[dependencies]
warden-sim = { path = "../../sim" }
[dev-dependencies]
# Dependency-free micro-benchmarks, same pattern as sim/benches/sim_bench.rs.
[[bench]]
name = "bridge_bench"
harness = false
+44
View File
@@ -0,0 +1,44 @@
//! Micro-benchmarks for the RS-485 bridge dispatch path — same dependency-free
//! fixed-iteration pattern as sim/benches/sim_bench.rs: human timings to
//! stdout, one JSON line per benchmark to stderr for CI trend capture.
//!
//! Run: `cargo bench` (or `cargo run --release --bench bridge_bench`).
use std::time::Instant;
use warden_rs485_bridge::{handle_control_line, Bus};
use warden_sim::modbus::read_holding;
fn bench<F: FnMut()>(name: &str, iters: u64, mut f: F) {
for _ in 0..(iters / 10).max(1) {
f(); // warm up
}
let t = Instant::now();
for _ in 0..iters {
f();
}
let ns = t.elapsed().as_nanos() as f64 / iters as f64;
println!("{name:<24} {ns:>9.1} ns/op ({iters} iters)");
eprintln!("{{\"bench\":\"{name}\",\"ns_per_op\":{ns:.1},\"iters\":{iters}}}");
}
fn main() {
const N: u64 = 1_000_000;
// Full request->reply dispatch through the locked slave (the per-poll cost
// a guest master pays on the simulated bus, minus socket I/O).
{
let bus = Bus::new(1, 128, 64);
let req = read_holding(1, 2, 4);
bench("bridge_dispatch", N, || {
let _ = bus.slave.lock().unwrap().handle_frame(&req);
});
}
// Control-channel command parse + register write.
{
let bus = Bus::new(1, 128, 64);
bench("control_line", N, || {
let _ = handle_control_line("holding 5=1234", &bus);
});
}
}
+388
View File
@@ -0,0 +1,388 @@
//! Bridge a QEMU serial chardev (unix socket) to `warden_sim::ModbusSlave`.
//!
//! The guest side is the *master* (flare-edge's `warden-modbus` scanner, polling
//! what it believes is /dev/ttyS4); this bridge is the wire and every slave on
//! it. Frames are delimited by an inter-frame gap of silence: RTU's 3.5-char
//! rule cannot survive a socket transport, so a wall-clock gap stands in for it.
//! A mis-split frame fails CRC inside `handle_frame`, which answers `None` —
//! exactly a real slave staying silent — and the master already treats silence
//! as a timeout, so the failure mode degrades to a dropped poll, never a
//! phantom reply.
//!
//! A second unix socket (the control channel) scripts the simulated bus from
//! test harnesses: fault injection (`drop`, `exception`, `clear`) and register
//! seeding/reading, one command per line.
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use std::sync::Mutex;
use std::time::Duration;
use warden_sim::ModbusSlave;
/// Default inter-frame gap. Generous next to real RTU (3.5 chars at 9600 baud
/// is ~4 ms) because a loaded host can stall a reader; the guest master's
/// response timeout is orders of magnitude larger.
pub const DEFAULT_GAP: Duration = Duration::from_millis(10);
/// Accumulation cap: a Modbus RTU ADU is at most 256 bytes, so anything past
/// 2x that without an inter-frame gap is a misbehaving master streaming
/// continuously — drop the buffer instead of growing without bound.
const MAX_PENDING: usize = 512;
/// The shared bus: the slave plus its declared dimensions. The sim's register
/// setters panic on out-of-range indices (deliberate test-harness semantics);
/// the control channel must bounds-check first so a typo in a scenario script
/// answers `err` instead of killing the VM's whole field bus.
pub struct Bus {
pub slave: Mutex<ModbusSlave>,
pub regs: usize,
pub bits: usize,
}
impl Bus {
pub fn new(address: u8, regs: usize, bits: usize) -> Self {
Bus {
slave: Mutex::new(ModbusSlave::new(address, regs, bits)),
regs,
bits,
}
}
}
/// Pump one serial connection until EOF: accumulate bytes, dispatch a frame to
/// the slave after `gap` of silence, write back the reply when the slave
/// answers. Any pending bytes are dispatched on EOF so a final unflushed frame
/// is not lost.
pub fn pump_serial(
stream: &UnixStream,
slave: &Mutex<ModbusSlave>,
gap: Duration,
) -> std::io::Result<()> {
stream.set_read_timeout(Some(gap))?;
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 256];
let mut discards: u64 = 0;
loop {
match (&*stream).read(&mut chunk) {
Ok(0) => {
if !buf.is_empty() {
dispatch(&mut buf, stream, slave)?;
}
return Ok(());
}
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() > MAX_PENDING {
// Rate-limit the log and back off for one gap so a master
// streaming continuously cannot peg a core and flood
// stderr — mirroring the accept-loop backoff.
discards += 1;
if discards == 1 || discards.is_multiple_of(256) {
eprintln!(
"rs485: {} bytes buffered with no inter-frame gap — \
discarding (misbehaving master? {} discards so far)",
buf.len(),
discards
);
}
buf.clear();
std::thread::sleep(gap);
}
}
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
if !buf.is_empty() {
dispatch(&mut buf, stream, slave)?;
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
}
fn dispatch(
buf: &mut Vec<u8>,
stream: &UnixStream,
slave: &Mutex<ModbusSlave>,
) -> std::io::Result<()> {
let reply = slave.lock().unwrap().handle_frame(buf);
match &reply {
Some(r) => eprintln!("rs485: {} -> {}", hex(buf), hex(r)),
None => eprintln!("rs485: {} -> (silence)", hex(buf)),
}
buf.clear();
if let Some(r) = reply {
(&*stream).write_all(&r)?;
}
Ok(())
}
fn hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join("")
}
/// Execute one control-channel command against the slave. One command per
/// line; the reply is `ok`, `ok <value>`, or `err <reason>`.
///
/// drop <n> answer the next n requests with silence
/// exception <code> NAK everything with this exception code (0x01..)
/// clear clear injected faults
/// holding <addr>=<v> seed a holding register
/// input <addr>=<v> seed an input register
/// coil <addr>=<0|1> seed a coil
/// discrete <addr>=<0|1> seed a discrete input
/// get-holding <addr> read a holding register back
/// get-coil <addr> read a coil back
/// ping liveness check
pub fn handle_control_line(line: &str, bus: &Bus) -> String {
let mut words = line.split_whitespace();
let cmd = match words.next() {
Some(c) => c,
None => return "err empty command".into(),
};
let arg = words.next();
if words.next().is_some() {
return format!("err trailing arguments after '{cmd}'");
}
// Each arm states its own bound (bus.regs for register space, bus.bits for
// bit space) INLINE — a previous string-keyed lookup defaulted silently to
// the bit bound, which would have handed a future `get-input` command the
// wrong range and reintroduced the out-of-range panic this check prevents.
let mut s = bus.slave.lock().unwrap();
match (cmd, arg) {
("ping", None) => "ok".into(),
("clear", None) => {
s.clear_faults();
"ok".into()
}
("drop", Some(n)) => match n.parse::<usize>() {
Ok(n) => {
s.drop_next(n);
"ok".into()
}
Err(_) => format!("err bad count '{n}'"),
},
("exception", Some(c)) => match parse_u16(c) {
Some(c) if c <= 0xff => {
s.force_exception(c as u8);
"ok".into()
}
_ => format!("err bad exception code '{c}'"),
},
("holding" | "input", Some(kv)) => match parse_addr_val(kv, bus.regs) {
Ok((a, v)) => {
if cmd == "holding" {
s.set_holding(a, v);
} else {
s.set_input(a, v);
}
"ok".into()
}
Err(e) => e,
},
("coil" | "discrete", Some(kv)) => match parse_addr_val(kv, bus.bits) {
Ok((a, v)) => {
if cmd == "coil" {
s.set_coil(a, v != 0);
} else {
s.set_discrete(a, v != 0);
}
"ok".into()
}
Err(e) => e,
},
("get-holding", Some(a)) => match parse_addr(a, bus.regs) {
Ok(a) => format!("ok {}", s.holding(a)),
Err(e) => e,
},
("get-coil", Some(a)) => match parse_addr(a, bus.bits) {
Ok(a) => format!("ok {}", u8::from(s.coil(a))),
Err(e) => e,
},
_ => format!("err unknown or malformed command '{line}'"),
}
}
/// Parse "<addr>=<value>" with the address bounds-checked against `bound`.
fn parse_addr_val(kv: &str, bound: usize) -> Result<(usize, u16), String> {
let Some((a, v)) = kv.split_once('=') else {
return Err(format!("err expected <addr>=<value>, got '{kv}'"));
};
let (Some(a), Some(v)) = (parse_u16(a), parse_u16(v)) else {
return Err(format!("err expected <addr>=<value>, got '{kv}'"));
};
if (a as usize) >= bound {
return Err(format!("err address {a} out of range (0..{bound})"));
}
Ok((a as usize, v))
}
/// Parse a bare address, bounds-checked against `bound`.
fn parse_addr(a: &str, bound: usize) -> Result<usize, String> {
match parse_u16(a) {
Some(v) if (v as usize) < bound => Ok(v as usize),
Some(v) => Err(format!("err address {v} out of range (0..{bound})")),
None => Err(format!("err bad address '{a}'")),
}
}
fn parse_u16(s: &str) -> Option<u16> {
if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u16::from_str_radix(h, 16).ok()
} else {
s.parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
use warden_sim::modbus::{crc_ok, read_holding};
// Test gap is much larger than DEFAULT_GAP so a loaded CI runner cannot
// split a frame the test wrote in two deliberate chunks: the 2ms
// inter-chunk pause has a 60x margin against the 120ms dispatch gap
// (25ms gave only 12.5x and was flagged as a flake risk on contended
// 2-vCPU hosted runners).
const GAP: Duration = Duration::from_millis(120);
const SETTLE: Duration = Duration::from_millis(400);
fn bus() -> Bus {
let b = Bus::new(1, 16, 16);
b.slave.lock().unwrap().set_holding(2, 0xbeef);
b
}
fn with_pump<F: FnOnce(&UnixStream)>(bus: &Bus, f: F) {
let (master, wire) = UnixStream::pair().unwrap();
thread::scope(|sc| {
sc.spawn(|| pump_serial(&wire, &bus.slave, GAP).unwrap());
f(&master);
master.shutdown(std::net::Shutdown::Both).unwrap();
});
}
fn read_reply(master: &UnixStream) -> Vec<u8> {
master
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0u8; 256];
let n = (&*master).read(&mut buf).expect("expected a reply frame");
buf[..n].to_vec()
}
#[test]
fn whole_frame_gets_a_valid_reply() {
let s = bus();
with_pump(&s, |master| {
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
let reply = read_reply(master);
assert!(crc_ok(&reply), "reply must carry a valid CRC");
// addr, fc, byte count, 0xbeef
assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
});
}
#[test]
fn frame_split_across_writes_within_gap_is_one_frame() {
let s = bus();
with_pump(&s, |master| {
let req = read_holding(1, 2, 1);
let (a, b) = req.split_at(3);
(&*master).write_all(a).unwrap();
thread::sleep(Duration::from_millis(2)); // well inside GAP
(&*master).write_all(b).unwrap();
let reply = read_reply(master);
assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
});
}
#[test]
fn two_frames_separated_by_gap_get_two_replies() {
let s = bus();
with_pump(&s, |master| {
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
let first = read_reply(master);
assert_eq!(&first[..5], &[1, 0x03, 2, 0xbe, 0xef]);
thread::sleep(SETTLE);
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
let second = read_reply(master);
assert_eq!(second, first);
});
}
#[test]
fn injected_drop_is_silence_then_recovery() {
let s = bus();
assert_eq!(handle_control_line("drop 1", &s), "ok");
with_pump(&s, |master| {
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
master
// Well past GAP: the dropped frame must have been dispatched
// (and answered with silence) before the next request is
// written, or the two would merge in the pending buffer.
.set_read_timeout(Some(Duration::from_millis(500)))
.unwrap();
let mut buf = [0u8; 16];
assert!(
(&*master).read(&mut buf).is_err(),
"dropped request must produce silence"
);
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
let reply = read_reply(master);
assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
});
}
#[test]
fn control_seeds_and_reads_registers() {
let s = bus();
assert_eq!(handle_control_line("holding 5=1234", &s), "ok");
assert_eq!(handle_control_line("get-holding 5", &s), "ok 1234");
assert_eq!(handle_control_line("coil 3=1", &s), "ok");
assert_eq!(handle_control_line("get-coil 3", &s), "ok 1");
assert_eq!(handle_control_line("holding 0xF=0xff", &s), "ok");
assert_eq!(handle_control_line("get-holding 15", &s), "ok 255");
// Out of range must answer err, never panic the bus (16-reg slave).
assert!(handle_control_line("holding 0x10=0xff", &s).starts_with("err"));
assert!(handle_control_line("get-holding 16", &s).starts_with("err"));
assert!(handle_control_line("coil 16=1", &s).starts_with("err"));
assert_eq!(handle_control_line("ping", &s), "ok");
}
#[test]
fn control_rejects_malformed_lines() {
let s = bus();
assert!(handle_control_line("", &s).starts_with("err"));
assert!(handle_control_line("drop many", &s).starts_with("err"));
assert!(handle_control_line("holding 5", &s).starts_with("err"));
assert!(handle_control_line("exception 300", &s).starts_with("err"));
assert!(handle_control_line("frobnicate 1", &s).starts_with("err"));
assert!(handle_control_line("drop 1 2", &s).starts_with("err"));
}
#[test]
fn forced_exception_naks_and_clear_recovers() {
let s = bus();
assert_eq!(handle_control_line("exception 0x02", &s), "ok");
with_pump(&s, |master| {
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
let nak = read_reply(master);
assert_eq!(&nak[..3], &[1, 0x83, 0x02], "fc|0x80 + exception code");
assert_eq!(handle_control_line("clear", &s), "ok");
thread::sleep(SETTLE);
(&*master).write_all(&read_holding(1, 2, 1)).unwrap();
let reply = read_reply(master);
assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
});
}
}
+136
View File
@@ -0,0 +1,136 @@
//! CLI wiring for the RS-485 bridge. All behavior lives in the lib (tested
//! there); this file only parses arguments, connects sockets, and spawns the
//! control listener.
//!
//! Typical use (matches qemu/run.sh --rs485). Put the sockets in a private
//! per-run directory (mktemp -d) — short (AF_UNIX caps paths at ~108 chars)
//! and not guessable/pre-creatable by other local users, unlike a fixed
//! /tmp name:
//!
//! d=$(mktemp -d /tmp/rs485.XXXXXX)
//! qemu/run.sh --kernel ... --rs485 "$d/serial.sock" &
//! rs485-bridge --serial "$d/serial.sock" --control "$d/ctl.sock"
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::process::exit;
use std::time::{Duration, Instant};
use warden_rs485_bridge::{handle_control_line, pump_serial, Bus, DEFAULT_GAP};
fn usage() -> ! {
eprintln!(
"usage: rs485-bridge --serial <sock> [--control <sock>] [--address N] \
[--regs N] [--bits N] [--gap-ms N]"
);
exit(2);
}
fn main() {
let mut serial: Option<String> = None;
let mut control: Option<String> = None;
let mut address: u8 = 1;
let mut regs: usize = 128;
let mut bits: usize = 64;
let mut gap = DEFAULT_GAP;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
let mut val = |name: &str| {
let v = args.next().unwrap_or_else(|| {
eprintln!("{name} needs a value");
usage()
});
// A following flag means the value was omitted — report the real
// problem instead of swallowing the flag as a bogus value.
if v.starts_with("--") {
eprintln!("{name} needs a value, got flag '{v}'");
usage()
}
v
};
match a.as_str() {
"--serial" => serial = Some(val("--serial")),
"--control" => control = Some(val("--control")),
"--address" => address = val("--address").parse().unwrap_or_else(|_| usage()),
"--regs" => regs = val("--regs").parse().unwrap_or_else(|_| usage()),
"--bits" => bits = val("--bits").parse().unwrap_or_else(|_| usage()),
"--gap-ms" => {
gap = Duration::from_millis(val("--gap-ms").parse().unwrap_or_else(|_| usage()))
}
_ => usage(),
}
}
let serial = serial.unwrap_or_else(|| usage());
// The bus is shared between the serial pump and the control channel.
// 'static so the control thread needs no scoped lifetime: the bridge runs
// until killed.
let bus: &'static Bus = Box::leak(Box::new(Bus::new(address, regs, bits)));
if let Some(path) = control {
// Clear a stale socket from a previous run. A failure here that is not
// "nothing to remove" (e.g. someone else's file behind /tmp's sticky
// bit) will make the bind below fail — surface both errors.
let removed = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap_or_else(|e| {
eprintln!("FATAL: cannot bind control socket {path}: {e}");
if let Err(re) = removed {
if re.kind() != std::io::ErrorKind::NotFound {
eprintln!(" (removing the pre-existing file also failed: {re})");
}
}
exit(1);
});
eprintln!("rs485: control socket at {path}");
std::thread::spawn(move || {
// Explicit error handling: `.flatten()` would turn a persistent
// accept() failure (fd exhaustion etc.) into a silent hot loop.
for conn in listener.incoming() {
let conn = match conn {
Ok(c) => c,
Err(e) => {
eprintln!("rs485: control accept failed: {e} — backing off");
std::thread::sleep(Duration::from_millis(200));
continue;
}
};
let reader = BufReader::new(conn.try_clone().expect("clone control conn"));
let mut writer = conn;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let reply = handle_control_line(&line, bus);
if writeln!(writer, "{reply}").is_err() {
break;
}
}
}
});
}
// QEMU (chardev server=on) may come up after us: retry the connect briefly
// instead of racing the VM launch.
let deadline = Instant::now() + Duration::from_secs(15);
let stream = loop {
match UnixStream::connect(&serial) {
Ok(s) => break s,
Err(e) if Instant::now() < deadline => {
eprintln!("rs485: waiting for {serial} ({e})");
std::thread::sleep(Duration::from_millis(500));
}
Err(e) => {
eprintln!("FATAL: cannot connect serial socket {serial}: {e}");
exit(1);
}
}
};
eprintln!("rs485: connected to {serial}, slave address {address}, gap {gap:?}");
if let Err(e) = pump_serial(&stream, &bus.slave, gap) {
eprintln!("FATAL: serial pump: {e}");
exit(1);
}
eprintln!("rs485: serial closed (VM gone), exiting");
}
Executable
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Launch the WardenOS device VM (qemu-system-arm -M virt, single Cortex-A7,
# 256M — the RV1106G3's shape). See qemu/README.md for what this does and does
# not emulate.
#
# Usage: run.sh --kernel <zImage> [options] [-- <extra qemu args>]
# --kernel PATH zImage (canonical or virt.fragment variant)
# --initrd PATH initramfs (default: qemu/out/initramfs.cpio.gz)
# --disk PATH virtio disk image from mkimage.sh (default: qemu/out/disk.img
# when present; pass --no-disk for a diskless initramfs boot)
# --no-disk boot without a disk (initramfs shell/smoke behavior)
# --slot _a|_b rootfs slot to boot (default _a)
# --rtc DATE guest RTC base, e.g. 2021-01-01 — reproduces the no-RTC
# "device boots believing 2021" incident class
# --rs485 SOCK unix socket chardev for the RS485/Modbus bridge
# (pci-serial: needs the virt.fragment kernel)
# --watchdog add i6300esb watchdog, reset on expiry (fragment kernel)
# --qmp SOCK QMP unix socket (screendump, input-send-event, quit)
# --display MODE off (default, -nographic) | on (gtk window) | headless
# (virtio-gpu without a window; screendump via --qmp)
# --ssh-port N hostfwd 127.0.0.1:N -> guest :22 (default 2222; 0 disables)
# --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080; 0 disables)
# --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443; 0 disables)
# --shell interactive shell in the guest instead of daemon hold
set -euo pipefail
QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=qemu/blkdevparts.conf disable=SC1091
. "$QEMU_DIR/blkdevparts.conf"
OUT="${OUT:-$QEMU_DIR/out}"
KERNEL="" INITRD="$OUT/initramfs.cpio.gz" DISK="" NO_DISK=0 SLOT="_a"
RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0
SSH_PORT=2222 HTTP_PORT=8080 API_PORT=28443
EXTRA=()
while [ $# -gt 0 ]; do
case "$1" in
--kernel) KERNEL="${2:?}"; shift 2 ;;
--initrd) INITRD="${2:?}"; shift 2 ;;
--disk) DISK="${2:?}"; shift 2 ;;
--no-disk) NO_DISK=1; shift ;;
--slot) SLOT="${2:?}"; shift 2 ;;
--rtc) RTC="${2:?}"; shift 2 ;;
--rs485) RS485="${2:?}"; shift 2 ;;
--watchdog) WATCHDOG=1; shift ;;
--qmp) QMP="${2:?}"; shift 2 ;;
--display) DISPLAY_MODE="${2:?}"; shift 2 ;;
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
--api-port) API_PORT="${2:?}"; shift 2 ;;
--shell) SHELL_FLAG=1; shift ;;
--) shift; EXTRA=("$@"); break ;;
*) echo "FATAL: unknown argument '$1' (see header of $0)" >&2; exit 1 ;;
esac
done
if [ -z "$KERNEL" ] || [ ! -f "$KERNEL" ]; then
echo "FATAL: --kernel <zImage> required and must exist (got '${KERNEL:-}')" >&2
exit 1
fi
[ -f "$INITRD" ] || {
echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh" >&2
exit 1
}
case "$SLOT" in _a|_b) ;; *) echo "FATAL: --slot must be _a or _b" >&2; exit 1 ;; esac
if [ "$NO_DISK" -eq 0 ] && [ -z "$DISK" ] && [ -f "$OUT/disk.img" ]; then
DISK="$OUT/disk.img"
fi
if [ -n "$DISK" ] && [ ! -f "$DISK" ]; then
echo "FATAL: disk image $DISK not found — run qemu/mkimage.sh (or pass --no-disk)" >&2
exit 1
fi
# NOTE: never add `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's
# 0xff4c0000, which does not exist on -M virt.
APPEND="console=ttyAMA0 rdinit=/init"
# Port 0 disables a forward — a boot smoke needs no host ports and must not
# fail on a busy default port.
NETDEV="user,id=n0"
[ "$SSH_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22"
[ "$HTTP_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80"
[ "$API_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${API_PORT}-:28443"
ARGS=(
-M "virt,highmem=off" -cpu cortex-a7 -smp 1 -m 256M
# virtio-mmio defaults to the legacy (0.9) transport; virtio-gpu and
# virtio-input are VERSION_1-only devices and never bind without this.
-global "virtio-mmio.force-legacy=false"
-kernel "$KERNEL" -initrd "$INITRD"
-netdev "$NETDEV"
-device "virtio-net-device,netdev=n0"
-no-reboot
)
if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then
APPEND="$APPEND blkdevparts=$WARDEN_BLKDEVPARTS warden.slot=$SLOT"
ARGS+=( -drive "if=none,file=$DISK,format=raw,id=vd0"
-device "virtio-blk-device,drive=vd0" )
fi
[ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell"
[ -n "$RTC" ] && ARGS+=( -rtc "base=$RTC" )
[ "$WATCHDOG" -eq 1 ] && ARGS+=( -device i6300esb -action watchdog=reset )
[ -n "$RS485" ] && ARGS+=( -chardev "socket,id=rs485,path=$RS485,server=on,wait=off"
-device "pci-serial,chardev=rs485" )
[ -n "$QMP" ] && ARGS+=( -qmp "unix:$QMP,server=on,wait=off" )
case "$DISPLAY_MODE" in
off) ARGS+=( -nographic ) ;;
on) ARGS+=( -device "virtio-gpu-device,xres=720,yres=720"
-device virtio-tablet-device -serial mon:stdio ) ;;
headless) ARGS+=( -device "virtio-gpu-device,xres=720,yres=720"
-device virtio-tablet-device -display none -serial mon:stdio ) ;;
*) echo "FATAL: --display must be off|on|headless" >&2; exit 1 ;;
esac
exec qemu-system-arm "${ARGS[@]}" -append "$APPEND" ${EXTRA[@]+"${EXTRA[@]}"}
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Boot the warden kernel under qemu-system-arm -M virt and assert the initramfs
# sentinel appears on the console. FAILS CLOSED: a missing zImage, initramfs,
# or qemu binary is an error, never a skip.
#
# Usage: boot-smoke.sh <zImage> [initramfs.cpio.gz]
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
ZIMAGE="${1:-}"
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
echo "FATAL: usage: $0 <zImage> [initramfs] — zImage missing or not a file: '${ZIMAGE:-}'" >&2
exit 1
fi
INITRD="${2:-$QDIR/out/initramfs.cpio.gz}"
[ -f "$INITRD" ] || {
echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh first" >&2
exit 1
}
command -v qemu-system-arm >/dev/null || {
echo "FATAL: qemu-system-arm not on PATH (apt-get install qemu-system-arm) — see qemu/README.md" >&2
exit 1
}
LOG="$(mktemp "${TMPDIR:-/tmp}/warden-qemu-smoke.XXXXXX")"
trap 'rm -f "$LOG"' EXIT
# Delegate the qemu invocation to run.sh (--no-disk) so the machine shape
# (-M virt,highmem=off, cpu, memory, virtio topology) lives in exactly one
# place — the two hand-copied invocations had already drifted once.
# timeout -k: a wedged qemu that ignores SIGTERM gets SIGKILLed 10s later
# instead of holding the job until the workflow-level timeout.
timeout -k 10 180 bash "$QDIR/run.sh" \
--kernel "$ZIMAGE" --initrd "$INITRD" --no-disk \
--ssh-port 0 --http-port 0 --api-port 0 \
</dev/null | tee "$LOG" || {
echo "FATAL: qemu exited non-zero (or hung until the 180s timeout)" >&2
exit 1
}
grep -q "WARDEN-QEMU-BOOT-OK" "$LOG" || {
echo "FATAL: boot sentinel WARDEN-QEMU-BOOT-OK not found in console log" >&2
exit 1
}
echo "boot smoke OK"
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# End-to-end device scenario: the REAL warden-flared, running inside the VM,
# checks in to flare-edge's mock FLARE portal on the host and pulls its
# firmware desired-state — the exact device-initiated HTTPS(-shaped) flow a
# panel performs, with zero flare-edge code changes (the portal URL is a state
# file; 10.0.2.2 is slirp's host alias).
#
# FAILS CLOSED on every missing prerequisite — never a soft skip.
#
# Usage: portal-scenario.sh <zImage-virt>
# Env: FLARE_EDGE path to a flare-edge checkout (provides mock-flare-portal.py)
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
ZIMAGE="${1:-}"
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
echo "FATAL: usage: $0 <zImage> — the virt.fragment kernel variant" >&2
exit 1
fi
if [ -z "${FLARE_EDGE:-}" ] || [ ! -f "$FLARE_EDGE/tools/mock-flare-portal.py" ]; then
echo "FATAL: FLARE_EDGE must point at a flare-edge checkout (mock-flare-portal.py not found under '${FLARE_EDGE:-}')" >&2
exit 1
fi
[ -x "$QDIR/payload/warden-flared" ] || {
echo "FATAL: no qemu/payload/warden-flared — build a static musl armv7 flared (see qemu/payload/README.md)" >&2
exit 1
}
command -v qemu-system-arm >/dev/null || {
echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
exit 1
}
# Short-named scratch: AF_UNIX socket paths are capped at ~108 chars.
WORK="$(mktemp -d /tmp/wqp.XXXXXX)"
QEMU_PID="" MOCK_PID=""
cleanup() {
if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
if [ -n "$MOCK_PID" ]; then kill "$MOCK_PID" 2>/dev/null || true; fi
rm -rf "$WORK"
}
trap cleanup EXIT
DEVICE_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
API_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
PORT=$((20000 + RANDOM % 20000))
# 0. a real signed tier-1 .wfw offer (version above the image's 0.0.1), so the
# scenario exercises desired-state -> download -> signature/hash verify, not
# just an empty 204. Signed with the committed desk key; the payload flared
# is a FLARED_DEV_KEY=1 build that trusts it (desk-testing only).
head -c 8388608 /dev/urandom > "$WORK/rootfs-payload.img"
FW_SIGNING_KEY_FILE="$FLARE_EDGE/tools/testdata/fw-dev-key.seed" \
WARDEN_KERNEL_VERSION=6.18.46 WARDEN_BUILDROOT_VERSION=2025.02 \
WARDEN_UBOOT_VERSION=2017.09 \
bash "$FLARE_EDGE/tools/mk-wfw.sh" "$WORK/rootfs-payload.img" 1 0.0.2 "$WORK/offer.wfw"
# 1. mock portal on the host, our device pre-registered (no pairing needed —
# the same credential-seeding shortcut fw-e2e-test.sh uses), offering the .wfw.
python3 "$FLARE_EDGE/tools/mock-flare-portal.py" \
--port "$PORT" --device "$DEVICE_ID:$API_KEY" \
--wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 &
MOCK_PID=$!
mock_ready=0
for _ in $(seq 1 50); do
curl -so /dev/null "http://127.0.0.1:$PORT/" && { mock_ready=1; break; }
kill -0 "$MOCK_PID" 2>/dev/null || { echo "FATAL: mock portal died:" >&2; cat "$WORK/mock.log" >&2; exit 1; }
sleep 0.2
done
# The loop must not fall through silently: an alive-but-unresponsive mock
# would otherwise surface 420s later as an unrelated assertion timeout.
[ "$mock_ready" = 1 ] || {
echo "FATAL: mock portal never answered on :$PORT within 10s" >&2
tail -10 "$WORK/mock.log" >&2
exit 1
}
echo "== mock portal on :$PORT, device $DEVICE_ID"
# 2. image seeded with the portal URL + credentials.
bash "$QDIR/mkinitramfs.sh"
# All four enrolment keys — flare::enrolment() returns None (and the report
# loop parks forever) unless flare.site is present too.
bash "$QDIR/mkimage.sh" \
--portal-url "http://10.0.2.2:$PORT" \
--state "flare.device_id=$DEVICE_ID" \
--state "flare.api_key=$API_KEY" \
--state "flare.site=qemu-devsim"
# 3. boot the VM headless (daemons run; console log to file). Random hostfwd
# ports can collide with another process — detect the early qemu bind
# failure and retry with a fresh base rather than failing spuriously.
QEMU_PID=""
for _attempt in 1 2 3; do
VMBASE=$((20000 + RANDOM % 20000))
: > "$WORK/console.log"
bash "$QDIR/run.sh" --kernel "$ZIMAGE" \
--ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \
> "$WORK/console.log" 2>&1 &
QEMU_PID=$!
sleep 3
if kill -0 "$QEMU_PID" 2>/dev/null; then
break
fi
if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
echo "== hostfwd port collision on base $VMBASE — retrying"
QEMU_PID=""
continue
fi
echo "FATAL: VM died at launch:" >&2
tail -20 "$WORK/console.log" >&2
exit 1
done
if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
echo "FATAL: could not launch the VM after 3 port attempts" >&2
exit 1
fi
# 4. assert: rootfs up, and the portal saw — from OUR device id — an
# authenticated check-in, the firmware desired-state pull, and the signed
# .wfw asset download (i.e. flared accepted the offer and fetched it; the
# verify+stage+APPLYING that follow are a dry run without
# WARDEN_FW_ALLOW_APPLY, exactly like fw-e2e-test.sh).
deadline=$((SECONDS + 420))
ok_boot=0 ok_report=0 ok_fw=0 ok_asset=0
while [ $SECONDS -lt $deadline ]; do
[ $ok_boot -eq 0 ] && grep -aq 'WARDEN-QEMU-ROOTFS-OK' "$WORK/console.log" && {
ok_boot=1; echo "== VM userspace up"; }
grep -aq "POST /api/v1/devices/$DEVICE_ID/report -> 200" "$WORK/mock.log" && ok_report=1
grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware -> 200" "$WORK/mock.log" && ok_fw=1
grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" && ok_asset=1
[ $ok_report -eq 1 ] && [ $ok_fw -eq 1 ] && [ $ok_asset -eq 1 ] && break
grep -aq 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" && {
echo "FATAL: guest partition mount failed (bad image?):" >&2
grep -a 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" >&2
exit 1
}
kill -0 "$QEMU_PID" 2>/dev/null || { echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; }
sleep 2
done
echo "== portal log (our device's requests):"
grep -a "$DEVICE_ID" "$WORK/mock.log" | tail -5 || true
fail=0
[ $ok_boot -eq 1 ] || { echo "FAIL: VM never reached WARDEN-QEMU-ROOTFS-OK"; fail=1; }
[ $ok_report -eq 1 ] || { echo "FAIL: no authenticated check-in (POST report 200) seen"; fail=1; }
[ $ok_fw -eq 1 ] || { echo "FAIL: no firmware desired-state pull (GET firmware 200) seen"; fail=1; }
[ $ok_asset -eq 1 ] || { echo "FAIL: signed .wfw asset was never downloaded"; fail=1; }
if [ $fail -eq 0 ]; then echo "PORTAL-SCENARIO-PASS"; else echo "PORTAL-SCENARIO-FAIL"; exit 1; fi
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Tiny QMP client for the qemu/ device-sim tests.
qmp.py <socket> screendump <out.ppm>
qmp.py <socket> tap <x> <y> # absolute 0..32767 (virtio-tablet)
qmp.py <socket> quit
"""
import json
import socket
import sys
import time
def rpc(sock, sock_file, obj):
sock.sendall((json.dumps(obj) + "\n").encode())
while True:
line = sock_file.readline()
if not line:
raise RuntimeError("QMP connection closed")
msg = json.loads(line)
if "return" in msg:
return msg["return"]
if "error" in msg:
raise RuntimeError(f"QMP error: {msg['error']}")
# asynchronous events are interleaved; skip them
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
path, cmd = sys.argv[1], sys.argv[2]
need = {"screendump": 4, "tap": 5, "quit": 3}
if cmd not in need:
sys.exit(f"unknown command {cmd}\n{__doc__}")
if len(sys.argv) < need[cmd]:
sys.exit(f"{cmd}: missing argument(s)\n{__doc__}")
s = socket.socket(socket.AF_UNIX)
s.connect(path)
f = s.makefile("r")
f.readline() # greeting banner
rpc(s, f, {"execute": "qmp_capabilities"})
if cmd == "screendump":
rpc(s, f, {"execute": "screendump", "arguments": {"filename": sys.argv[3]}})
elif cmd == "tap":
x, y = int(sys.argv[3]), int(sys.argv[4])
press = [
{"type": "abs", "data": {"axis": "x", "value": x}},
{"type": "abs", "data": {"axis": "y", "value": y}},
{"type": "btn", "data": {"down": True, "button": "left"}},
]
release = [{"type": "btn", "data": {"down": False, "button": "left"}}]
rpc(s, f, {"execute": "input-send-event", "arguments": {"events": press}})
# Hold the press across several LVGL indev poll periods (33 ms each):
# an instantaneous press+release lands inside one poll and no click
# is ever registered.
time.sleep(0.2)
rpc(s, f, {"execute": "input-send-event", "arguments": {"events": release}})
elif cmd == "quit":
s.sendall(b'{"execute":"quit"}\n')
if __name__ == "__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Display + touch scenario: boot the VM headless with virtio-gpu, wait for the
# LVGL UI (fbdev build) to render a real frame, then inject an absolute touch
# tap on the Metrics tab (virtio-tablet) and ASSERT the frame changed — the
# device-level analogue of flare-edge's Xvfb/xdotool sim-test.sh. Readiness is
# polled from screendumps on bounded deadlines, never guessed with fixed
# sleeps: TCG renders CPU-bound and a loaded host can be arbitrarily slow.
#
# FAILS CLOSED on missing prerequisites.
#
# Usage: ui-shot.sh <zImage-virt> [out-dir]
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
QDIR="$(cd "$HERE/.." && pwd)" # qemu/
ZIMAGE="${1:-}"
OUTDIR="${2:-$QDIR/out}"
if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
echo "FATAL: usage: $0 <zImage> [out-dir] — the virt.fragment kernel variant" >&2
exit 1
fi
[ -x "$QDIR/payload/warden-ui" ] || {
echo "FATAL: no qemu/payload/warden-ui — build it with flare-edge tools/build-ui-vm.sh" >&2
exit 1
}
command -v qemu-system-arm >/dev/null || {
echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
exit 1
}
# Short-named scratch: AF_UNIX socket paths are capped at ~108 chars.
WORK="$(mktemp -d /tmp/wqu.XXXXXX)"
QEMU_PID=""
cleanup() {
if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
rm -rf "$WORK"
}
trap cleanup EXIT
bash "$QDIR/mkinitramfs.sh"
bash "$QDIR/mkimage.sh"
# Random hostfwd ports can collide — detect qemu's early bind failure and
# retry with a fresh base rather than failing spuriously.
for _attempt in 1 2 3; do
PORT=$((21000 + RANDOM % 20000))
: > "$WORK/console.log"
bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \
--ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
> "$WORK/console.log" 2>&1 &
QEMU_PID=$!
sleep 3
kill -0 "$QEMU_PID" 2>/dev/null && break
if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
echo "== hostfwd port collision on base $PORT — retrying"
QEMU_PID=""
continue
fi
echo "FATAL: VM died at launch:" >&2
tail -20 "$WORK/console.log" >&2
exit 1
done
if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
echo "FATAL: could not launch the VM after 3 port attempts" >&2
exit 1
fi
deadline=$((SECONDS + 120))
while [ $SECONDS -lt $deadline ]; do
grep -aq 'init: starting warden-ui' "$WORK/console.log" && break
kill -0 "$QEMU_PID" 2>/dev/null || { echo "FATAL: VM exited early" >&2; tail -25 "$WORK/console.log" >&2; exit 1; }
sleep 2
done
grep -aq 'init: starting warden-ui' "$WORK/console.log" || {
echo "FATAL: warden-ui never started (no fb0? wrong kernel?)" >&2
tail -25 "$WORK/console.log" >&2
exit 1
}
qmp() { python3 "$HERE/qmp.py" "$WORK/qmp.sock" "$@"; }
# Frame is "real" once it has more than a handful of distinct colors (a blank
# or console-only frame has very few).
frame_rendered() { # $1 = ppm path
python3 - "$1" <<'EOF'
import sys
data = open(sys.argv[1], "rb").read()
parts = data.split(b"\n", 3) # P6 header: magic, dims, maxval, raw RGB
pixels = parts[3] if len(parts) == 4 else b""
distinct = len(set(pixels[i:i+3] for i in range(0, min(len(pixels), 3*720*720), 3)))
print(f"{sys.argv[1]}: {len(pixels)} bytes, {distinct} distinct colors")
sys.exit(0 if distinct > 16 else 1)
EOF
}
# The VM can die mid-poll (OOM, crash): check liveness before every QMP
# call so the failure is OUR message + console evidence, not a python
# traceback — and preserve the console log before the trap removes $WORK.
vm_alive_or_die() {
kill -0 "$QEMU_PID" 2>/dev/null && return 0
echo "FATAL: VM exited during the screendump poll" >&2
tail -25 "$WORK/console.log" >&2
mkdir -p "$OUTDIR"; cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
exit 1
}
# Poll for the first rendered frame (bounded, no guessed sleep).
rendered=0
deadline=$((SECONDS + 90))
while [ $SECONDS -lt $deadline ]; do
vm_alive_or_die
qmp screendump "$WORK/shot1.ppm"
if frame_rendered "$WORK/shot1.ppm"; then rendered=1; break; fi
sleep 3
done
[ "$rendered" = 1 ] || {
echo "FATAL: UI never rendered a non-blank frame within 90s" >&2
mkdir -p "$OUTDIR"; cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
exit 1
}
# Tap the "Metrics" tab: pixel (373,40) of 720x720 scaled to the QMP absolute
# range 0..32767 — switching tabs must repaint the content area. Poll for the
# repaint rather than guessing a delay.
vm_alive_or_die
qmp tap 16975 1820
changed=0
# 90s, matching the first-frame budget: TCG repaints are CPU-bound and a
# contended CI runner can be arbitrarily slower than this dev box (same
# margin reasoning as the rs485 test-gap widening).
deadline=$((SECONDS + 90))
while [ $SECONDS -lt $deadline ]; do
sleep 2
vm_alive_or_die
qmp screendump "$WORK/shot2.ppm"
if ! cmp -s "$WORK/shot1.ppm" "$WORK/shot2.ppm"; then changed=1; break; fi
done
mkdir -p "$OUTDIR"
cp "$WORK/shot1.ppm" "$OUTDIR/ui-shot1.ppm"
cp "$WORK/shot2.ppm" "$OUTDIR/ui-shot2.ppm" 2>/dev/null || true
[ "$changed" = 1 ] || {
echo "FATAL: tapping the Metrics tab did not change the frame within 90s — touch is not reaching the UI" >&2
cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
exit 1
}
echo "tap on the Metrics tab repainted the frame (touch reached the UI)"
echo "UI-SHOT-PASS (screenshots in $OUTDIR/ui-shot{1,2}.ppm)"
+1 -1
View File
@@ -3,7 +3,7 @@ name = "warden-sim"
version = "0.1.0"
edition = "2021"
description = "Host-side hardware simulator for WardenOS: register/SRAM bus, HPMCU (RISC-V) watchdog, RGA, NPU. Lets driver and supervisor logic run and be tested with no panel."
license = "MIT OR Apache-2.0"
license = "GPL-2.0-only"
[lib]
name = "warden_sim"
+1 -1
View File
@@ -3,7 +3,7 @@ name = "warden-config-lint"
version = "0.1.0"
edition = "2021"
description = "Static target-config checks for WardenOS: catch memory-map faults (the 0x40000 MCU-load brick class) and other flash-time config mistakes before a flash, not on the bench."
license = "MIT OR Apache-2.0"
license = "GPL-2.0-only"
[[bin]]
name = "config-lint"