diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3a7bb..8f3f633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index 862a5b0..471c91e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE index b0b40ae..9efa6fb 100644 --- a/LICENSE +++ b/LICENSE @@ -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., + + 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. + + + Copyright (C) + + 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 . + +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. + + , 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. diff --git a/LICENSE-APACHE b/LICENSE-APACHE deleted file mode 100644 index d645695..0000000 --- a/LICENSE-APACHE +++ /dev/null @@ -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. diff --git a/LICENSE-MIT b/LICENSE-MIT deleted file mode 100644 index 1f8cf8c..0000000 --- a/LICENSE-MIT +++ /dev/null @@ -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. diff --git a/README.md b/README.md index 7cc502b..e39d0ca 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/build/build-kernel.sh b/build/build-kernel.sh index 4e5c832..73f097c 100755 --- a/build/build-kernel.sh +++ b/build/build-kernel.sh @@ -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" diff --git a/build/fetch-kernel-tarball.sh b/build/fetch-kernel-tarball.sh new file mode 100644 index 0000000..6d16cbe --- /dev/null +++ b/build/fetch-kernel-tarball.sh @@ -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 +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 }" + +# 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" diff --git a/docs/architecture.md b/docs/architecture.md index d3c79e7..812773d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 0x01–0x06/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). diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 1e9e358..0873833 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -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 1–2 -> below are the record of that install (they needed `user`'s sudo on 0640); steps -> 3–4 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. diff --git a/docs/decisions/0003-standalone-repo.md b/docs/decisions/0003-standalone-repo.md index 36aa658..b08f93c 100644 --- a/docs/decisions/0003-standalone-repo.md +++ b/docs/decisions/0003-standalone-repo.md @@ -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. diff --git a/docs/decisions/0004-ci-runner.md b/docs/decisions/0004-ci-runner.md index 847098e..9fd1fc6 100644 --- a/docs/decisions/0004-ci-runner.md +++ b/docs/decisions/0004-ci-runner.md @@ -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 diff --git a/docs/decisions/0005-driver-source-of-truth.md b/docs/decisions/0005-driver-source-of-truth.md index 053007c..8ac83bc 100644 --- a/docs/decisions/0005-driver-source-of-truth.md +++ b/docs/decisions/0005-driver-source-of-truth.md @@ -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`). diff --git a/docs/decisions/0006-qemu-device-sim.md b/docs/decisions/0006-qemu-device-sim.md new file mode 100644 index 0000000..cd23ce9 --- /dev/null +++ b/docs/decisions/0006-qemu-device-sim.md @@ -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. diff --git a/docs/decisions/0007-public-repo-hosted-kernel-build.md b/docs/decisions/0007-public-repo-hosted-kernel-build.md new file mode 100644 index 0000000..f568587 --- /dev/null +++ b/docs/decisions/0007-public-repo-hosted-kernel-build.md @@ -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. diff --git a/drivers/README.md b/drivers/README.md index b197586..2cee72b 100644 --- a/drivers/README.md +++ b/drivers/README.md @@ -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. diff --git a/drivers/enforce-mcdc.sh b/drivers/enforce-mcdc.sh index 9cb2d4c..7206e08 100755 --- a/drivers/enforce-mcdc.sh +++ b/drivers/enforce-mcdc.sh @@ -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" diff --git a/kernel/docs/bringup.md b/kernel/docs/bringup.md index 0d05b64..6f8bc4c 100644 --- a/kernel/docs/bringup.md +++ b/kernel/docs/bringup.md @@ -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. diff --git a/kernel/docs/m2-boot-on-c8a3.md b/kernel/docs/m2-boot-on-c8a3.md index 4f09b51..97e516a 100644 --- a/kernel/docs/m2-boot-on-c8a3.md +++ b/kernel/docs/m2-boot-on-c8a3.md @@ -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. diff --git a/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md b/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md index fd06830..7a10279 100644 --- a/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md +++ b/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md @@ -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) diff --git a/kernel/rv1106-enablement/DRIVER-PARITY.md b/kernel/rv1106-enablement/DRIVER-PARITY.md index 0fff95a..133c49f 100644 --- a/kernel/rv1106-enablement/DRIVER-PARITY.md +++ b/kernel/rv1106-enablement/DRIVER-PARITY.md @@ -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 0–4) | -| 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 0–4) | +| 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. diff --git a/kernel/rv1106-enablement/OVERNIGHT-PLAN.md b/kernel/rv1106-enablement/OVERNIGHT-PLAN.md index 5f2ff9e..27f6bd2 100644 --- a/kernel/rv1106-enablement/OVERNIGHT-PLAN.md +++ b/kernel/rv1106-enablement/OVERNIGHT-PLAN.md @@ -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 — diff --git a/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md b/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md index f51264f..791a6d0 100644 --- a/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md +++ b/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md @@ -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. diff --git a/kernel/rv1106-enablement/PORT-STATUS.md b/kernel/rv1106-enablement/PORT-STATUS.md index 14df286..3a0b640 100644 --- a/kernel/rv1106-enablement/PORT-STATUS.md +++ b/kernel/rv1106-enablement/PORT-STATUS.md @@ -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: ``` diff --git a/kernel/rv1106-enablement/PROVENANCE.md b/kernel/rv1106-enablement/PROVENANCE.md index 76f69eb..d350243 100644 --- a/kernel/rv1106-enablement/PROVENANCE.md +++ b/kernel/rv1106-enablement/PROVENANCE.md @@ -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 diff --git a/kernel/rv1106-enablement/adc/SARADC-FIX.md b/kernel/rv1106-enablement/adc/SARADC-FIX.md index d293cda..15911d0 100644 --- a/kernel/rv1106-enablement/adc/SARADC-FIX.md +++ b/kernel/rv1106-enablement/adc/SARADC-FIX.md @@ -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 diff --git a/kernel/rv1106-enablement/audio/PORT-PROGRESS.md b/kernel/rv1106-enablement/audio/PORT-PROGRESS.md index 42e38d8..f1aeee3 100644 --- a/kernel/rv1106-enablement/audio/PORT-PROGRESS.md +++ b/kernel/rv1106-enablement/audio/PORT-PROGRESS.md @@ -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). diff --git a/kernel/rv1106-enablement/build-m2.sh b/kernel/rv1106-enablement/build-m2.sh index 9a651dd..c8b5f90 100755 --- a/kernel/rv1106-enablement/build-m2.sh +++ b/kernel/rv1106-enablement/build-m2.sh @@ -8,7 +8,7 @@ # Defaults match this workspace. set -euo pipefail -FE="${FE:-}" +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)" diff --git a/kernel/rv1106-enablement/display/README.md b/kernel/rv1106-enablement/display/README.md index ebd4326..e44680e 100644 --- a/kernel/rv1106-enablement/display/README.md +++ b/kernel/rv1106-enablement/display/README.md @@ -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. diff --git a/kernel/rv1106-enablement/display/VERIFIED.md b/kernel/rv1106-enablement/display/VERIFIED.md index a73f1f0..c2d3236 100644 --- a/kernel/rv1106-enablement/display/VERIFIED.md +++ b/kernel/rv1106-enablement/display/VERIFIED.md @@ -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 diff --git a/kernel/rv1106-enablement/gmac/PORT-DONE.md b/kernel/rv1106-enablement/gmac/PORT-DONE.md index ca1f03c..e915a75 100644 --- a/kernel/rv1106-enablement/gmac/PORT-DONE.md +++ b/kernel/rv1106-enablement/gmac/PORT-DONE.md @@ -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 diff --git a/kernel/rv1106-enablement/mailbox/PLAN.md b/kernel/rv1106-enablement/mailbox/PLAN.md index 74b421a..2d03d84 100644 --- a/kernel/rv1106-enablement/mailbox/PLAN.md +++ b/kernel/rv1106-enablement/mailbox/PLAN.md @@ -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`, diff --git a/kernel/rv1106-enablement/mailbox/VERIFIED.md b/kernel/rv1106-enablement/mailbox/VERIFIED.md index 793b2c3..733520e 100644 --- a/kernel/rv1106-enablement/mailbox/VERIFIED.md +++ b/kernel/rv1106-enablement/mailbox/VERIFIED.md @@ -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. diff --git a/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile b/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile index 5008b9f..54678a7 100644 --- a/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile +++ b/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile @@ -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 ?= /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 diff --git a/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md b/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md index ec4a2eb..7f2e372 100644 --- a/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md +++ b/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md @@ -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`._ diff --git a/kernel/rv1106-enablement/npu/PORT-PLAN.md b/kernel/rv1106-enablement/npu/PORT-PLAN.md index c096b54..da06af7 100644 --- a/kernel/rv1106-enablement/npu/PORT-PLAN.md +++ b/kernel/rv1106-enablement/npu/PORT-PLAN.md @@ -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). diff --git a/kernel/rv1106-enablement/npu/PORT-PROGRESS.md b/kernel/rv1106-enablement/npu/PORT-PROGRESS.md index 905aecd..7264463 100644 --- a/kernel/rv1106-enablement/npu/PORT-PROGRESS.md +++ b/kernel/rv1106-enablement/npu/PORT-PROGRESS.md @@ -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`. diff --git a/kernel/rv1106-enablement/npu/VERIFIED.md b/kernel/rv1106-enablement/npu/VERIFIED.md index fa4e5d9..f6ddfe0 100644 --- a/kernel/rv1106-enablement/npu/VERIFIED.md +++ b/kernel/rv1106-enablement/npu/VERIFIED.md @@ -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 diff --git a/kernel/rv1106-enablement/pvtm/PORT-DONE.md b/kernel/rv1106-enablement/pvtm/PORT-DONE.md index f5ba37a..03d99bf 100644 --- a/kernel/rv1106-enablement/pvtm/PORT-DONE.md +++ b/kernel/rv1106-enablement/pvtm/PORT-DONE.md @@ -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 diff --git a/kernel/rv1106-enablement/rga/PORT-PLAN.md b/kernel/rv1106-enablement/rga/PORT-PLAN.md index c6660db..6e1c01b 100644 --- a/kernel/rv1106-enablement/rga/PORT-PLAN.md +++ b/kernel/rv1106-enablement/rga/PORT-PLAN.md @@ -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 diff --git a/kernel/rv1106-enablement/rng-otp/PORT-DONE.md b/kernel/rv1106-enablement/rng-otp/PORT-DONE.md index 46cfc86..a96688f 100644 --- a/kernel/rv1106-enablement/rng-otp/PORT-DONE.md +++ b/kernel/rv1106-enablement/rng-otp/PORT-DONE.md @@ -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. diff --git a/kernel/rv1106-enablement/touch/VERIFIED.md b/kernel/rv1106-enablement/touch/VERIFIED.md index 383c9c5..13dd1db 100644 --- a/kernel/rv1106-enablement/touch/VERIFIED.md +++ b/kernel/rv1106-enablement/touch/VERIFIED.md @@ -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 diff --git a/kernel/rv1106-enablement/wifi/PORT-PLAN.md b/kernel/rv1106-enablement/wifi/PORT-PLAN.md index 895dfa7..17c5c9f 100644 --- a/kernel/rv1106-enablement/wifi/PORT-PLAN.md +++ b/kernel/rv1106-enablement/wifi/PORT-PLAN.md @@ -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 diff --git a/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md b/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md index dc16636..25d937b 100644 --- a/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md +++ b/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md @@ -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: ... 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: ... link/ether ` +- `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) diff --git a/patches/README.md b/patches/README.md index 44023d7..6195373 100644 --- a/patches/README.md +++ b/patches/README.md @@ -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` diff --git a/qemu/README.md b/qemu/README.md new file mode 100644 index 0000000..02af94b --- /dev/null +++ b/qemu/README.md @@ -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 ` — sentinel-asserting boot; runs in CI inside the + kernel-build job. +- `portal-scenario.sh ` (needs `FLARE_EDGE=`) — 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 ` — 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). diff --git a/qemu/blkdevparts.conf b/qemu/blkdevparts.conf new file mode 100644 index 0000000..4778504 --- /dev/null +++ b/qemu/blkdevparts.conf @@ -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)' diff --git a/qemu/busybox.sha256 b/qemu/busybox.sha256 new file mode 100644 index 0000000..35abe05 --- /dev/null +++ b/qemu/busybox.sha256 @@ -0,0 +1 @@ +cd04052b8b6885f75f50b2a280bfcbf849d8710c8e61d369c533acf307eda064 diff --git a/qemu/configs/virt.fragment b/qemu/configs/virt.fragment new file mode 100644 index 0000000..e833b3a --- /dev/null +++ b/qemu/configs/virt.fragment @@ -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 diff --git a/qemu/lib.sh b/qemu/lib.sh new file mode 100644 index 0000000..66324b7 --- /dev/null +++ b/qemu/lib.sh @@ -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)) +} diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh new file mode 100755 index 0000000..7b85626 --- /dev/null +++ b/qemu/mkimage.sh @@ -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)" diff --git a/qemu/mkinitramfs.sh b/qemu/mkinitramfs.sh new file mode 100755 index 0000000..9146188 --- /dev/null +++ b/qemu/mkinitramfs.sh @@ -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))" diff --git a/qemu/payload/README.md b/qemu/payload/README.md new file mode 100644 index 0000000..f5554a8 --- /dev/null +++ b/qemu/payload/README.md @@ -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 /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/.log` inside +the guest). An empty payload is valid — the image boots busybox-only. diff --git a/qemu/rootfs/etc/rc b/qemu/rootfs/etc/rc new file mode 100755 index 0000000..b6ac1e3 --- /dev/null +++ b/qemu/rootfs/etc/rc @@ -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 diff --git a/qemu/rootfs/etc/warden-lib.sh b/qemu/rootfs/etc/warden-lib.sh new file mode 100644 index 0000000..6e24d10 --- /dev/null +++ b/qemu/rootfs/etc/warden-lib.sh @@ -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/ 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" +} diff --git a/qemu/rootfs/init b/qemu/rootfs/init new file mode 100755 index 0000000..948f165 --- /dev/null +++ b/qemu/rootfs/init @@ -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 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 diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init new file mode 100755 index 0000000..bf5626e --- /dev/null +++ b/qemu/rootfs/sbin/init @@ -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 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 diff --git a/qemu/rootfs/usr/share/udhcpc/default.script b/qemu/rootfs/usr/share/udhcpc/default.script new file mode 100755 index 0000000..3020db7 --- /dev/null +++ b/qemu/rootfs/usr/share/udhcpc/default.script @@ -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 diff --git a/qemu/rs485-bridge/Cargo.lock b/qemu/rs485-bridge/Cargo.lock new file mode 100644 index 0000000..769b4fe --- /dev/null +++ b/qemu/rs485-bridge/Cargo.lock @@ -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" diff --git a/qemu/rs485-bridge/Cargo.toml b/qemu/rs485-bridge/Cargo.toml new file mode 100644 index 0000000..6950b87 --- /dev/null +++ b/qemu/rs485-bridge/Cargo.toml @@ -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 diff --git a/qemu/rs485-bridge/benches/bridge_bench.rs b/qemu/rs485-bridge/benches/bridge_bench.rs new file mode 100644 index 0000000..b8f8fd2 --- /dev/null +++ b/qemu/rs485-bridge/benches/bridge_bench.rs @@ -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(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); + }); + } +} diff --git a/qemu/rs485-bridge/src/lib.rs b/qemu/rs485-bridge/src/lib.rs new file mode 100644 index 0000000..22f0b22 --- /dev/null +++ b/qemu/rs485-bridge/src/lib.rs @@ -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, + 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, + gap: Duration, +) -> std::io::Result<()> { + stream.set_read_timeout(Some(gap))?; + let mut buf: Vec = 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, + stream: &UnixStream, + slave: &Mutex, +) -> 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::>() + .join("") +} + +/// Execute one control-channel command against the slave. One command per +/// line; the reply is `ok`, `ok `, or `err `. +/// +/// drop answer the next n requests with silence +/// exception NAK everything with this exception code (0x01..) +/// clear clear injected faults +/// holding = seed a holding register +/// input = seed an input register +/// coil =<0|1> seed a coil +/// discrete =<0|1> seed a discrete input +/// get-holding read a holding register back +/// get-coil 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::() { + 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 "=" 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 =, got '{kv}'")); + }; + let (Some(a), Some(v)) = (parse_u16(a), parse_u16(v)) else { + return Err(format!("err expected =, 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 { + 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 { + 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(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 { + 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]); + }); + } +} diff --git a/qemu/rs485-bridge/src/main.rs b/qemu/rs485-bridge/src/main.rs new file mode 100644 index 0000000..6e1cc7e --- /dev/null +++ b/qemu/rs485-bridge/src/main.rs @@ -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 [--control ] [--address N] \ + [--regs N] [--bits N] [--gap-ms N]" + ); + exit(2); +} + +fn main() { + let mut serial: Option = None; + let mut control: Option = 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"); +} diff --git a/qemu/run.sh b/qemu/run.sh new file mode 100755 index 0000000..a40dde5 --- /dev/null +++ b/qemu/run.sh @@ -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 [options] [-- ] +# --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 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[@]}"} diff --git a/qemu/tests/boot-smoke.sh b/qemu/tests/boot-smoke.sh new file mode 100755 index 0000000..15338e4 --- /dev/null +++ b/qemu/tests/boot-smoke.sh @@ -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 [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 [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 \ + &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" diff --git a/qemu/tests/portal-scenario.sh b/qemu/tests/portal-scenario.sh new file mode 100755 index 0000000..89e101f --- /dev/null +++ b/qemu/tests/portal-scenario.sh @@ -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 +# 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 — 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 diff --git a/qemu/tests/qmp.py b/qemu/tests/qmp.py new file mode 100755 index 0000000..0737cfb --- /dev/null +++ b/qemu/tests/qmp.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Tiny QMP client for the qemu/ device-sim tests. + + qmp.py screendump + qmp.py tap # absolute 0..32767 (virtio-tablet) + qmp.py 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() diff --git a/qemu/tests/ui-shot.sh b/qemu/tests/ui-shot.sh new file mode 100755 index 0000000..4b4fdb7 --- /dev/null +++ b/qemu/tests/ui-shot.sh @@ -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 [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 [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)" diff --git a/sim/Cargo.toml b/sim/Cargo.toml index d7e4f6e..21c40cf 100644 --- a/sim/Cargo.toml +++ b/sim/Cargo.toml @@ -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" diff --git a/tools/config-lint/Cargo.toml b/tools/config-lint/Cargo.toml index a2efd61..578fd08 100644 --- a/tools/config-lint/Cargo.toml +++ b/tools/config-lint/Cargo.toml @@ -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"