مقایسه کامیتها
19
کامیتها
3cfe096d81
..
main
| مولف | SHA1 | تاریخ | |
|---|---|---|---|
|
|
5be306ad1f | ||
|
|
fc45c88708 | ||
|
|
68583fd185 | ||
|
|
0528032639 | ||
|
|
9b5bff8002 | ||
|
|
b38dd9baf8 | ||
|
|
cdf22287fe | ||
|
|
3c470a8fea | ||
|
|
b5adbec417 | ||
|
|
106084d6a4 | ||
|
|
c9d72c6319 | ||
|
|
793504fc78 | ||
|
|
b452fceb82 | ||
|
|
25caf97633 | ||
|
|
cdd505b6bc | ||
|
|
5ee716f38a | ||
|
|
84789b3dad | ||
|
|
613828da54 | ||
|
|
ca7ae6df6c |
@@ -0,0 +1,28 @@
|
||||
name: Inspect and Configure Boot Arguments
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_squashfs_mtdblock3:
|
||||
description: 'Confirm RootFS is actually SquashFS on mtdblock3 and set bootargs'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
boot-config:
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Inspect boot settings and optionally set verified bootargs
|
||||
env:
|
||||
CONFIRM_SQUASHFS_MTDBLOCK3: ${{ inputs.confirm_squashfs_mtdblock3 }}
|
||||
run: |
|
||||
if [ "$CONFIRM_SQUASHFS_MTDBLOCK3" = "true" ]; then
|
||||
/usr/bin/python3 deployment/serial/serial_command.py boot-config --confirm-squashfs-mtdblock3
|
||||
else
|
||||
/usr/bin/python3 deployment/serial/serial_command.py boot-config
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Configure Boot Command for Detected Flash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
set-bootcmd:
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect flash, set bootcmd and verify
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py set-bootcmd
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Transfer, Write and Verify DTB
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dtb-transfer:
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Transfer DTB, write SPI flash and verify read-back
|
||||
run: |
|
||||
/usr/bin/python3 deployment/serial/serial_command.py transfer-dtb
|
||||
@@ -0,0 +1,78 @@
|
||||
name: flash-aminpa-V1.5.yml
|
||||
# Production hardware flashing for Amin Pardaz Asia device v1.5
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: aminpa-serial-flash
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
flash-aminpa:
|
||||
name: Hardware flashing - Amin Pardaz Asia v1.5
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate all firmware files before erasing flash
|
||||
run: |
|
||||
/usr/bin/python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
required = {
|
||||
"u-boot-sunxi-with-spl.bin": 32,
|
||||
"sun8i-v3s-licheepi-zero-dock.dtb": 32,
|
||||
"zImage": 64,
|
||||
"rootfs.squashfs": 64,
|
||||
}
|
||||
for filename, minimum in required.items():
|
||||
path = Path("firmware") / filename
|
||||
if not path.is_file() or path.stat().st_size < minimum:
|
||||
raise SystemExit(f"Missing or invalid firmware: {path}")
|
||||
print(f"Found {path}: {path.stat().st_size} bytes")
|
||||
PY
|
||||
|
||||
- name: Check current U-Boot CLI
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py check
|
||||
|
||||
- name: Configure and verify network
|
||||
env:
|
||||
UBOOT_DEVICE_IP: ${{ vars.UBOOT_DEVICE_IP || '192.168.75.100' }}
|
||||
TFTP_SERVER_IP: ${{ vars.TFTP_SERVER_IP || '192.168.75.247' }}
|
||||
run: |
|
||||
/usr/bin/python3 deployment/serial/serial_command.py configure --device-ip "$UBOOT_DEVICE_IP" --tftp-server-ip "$TFTP_SERVER_IP"
|
||||
|
||||
- name: Check all firmware sizes against detected flash layout
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py preflight
|
||||
|
||||
- name: Erase detected SPI flash and verify sample
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py erase
|
||||
|
||||
- name: Transfer, write and verify U-Boot
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py transfer
|
||||
|
||||
- name: Transfer, write and verify DTB
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py transfer-dtb
|
||||
|
||||
- name: Transfer, write and verify Kernel
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py transfer-kernel
|
||||
|
||||
- name: Transfer, write and verify RootFS
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py transfer-rootfs
|
||||
|
||||
- name: Inspect boot configuration
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py boot-config
|
||||
|
||||
- name: Configure and verify bootcmd for detected flash
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py set-bootcmd
|
||||
|
||||
# - name: Save U-Boot environment before reboot
|
||||
# run: /usr/bin/python3 deployment/serial/serial_command.py saveenv
|
||||
|
||||
- name: Reboot hardware after successful flashing
|
||||
run: /usr/bin/python3 deployment/serial/serial_command.py reboot
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Detect and Erase Entire SPI Flash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
flash-erase:
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Erase entire flash and verify first 256 bytes
|
||||
run: |
|
||||
/usr/bin/python3 deployment/serial/serial_command.py erase
|
||||
@@ -0,0 +1,24 @@
|
||||
name: init test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
init-test:
|
||||
runs-on: serial-runner
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify ordered boot and Linux console
|
||||
env:
|
||||
RUTBS_PASSWORD: ${{ secrets.RUTBS_PASSWORD }}
|
||||
run: /usr/bin/python3 deployment/serial/init_test.py
|
||||
|
||||
- name: Upload redacted serial log
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: boot-log
|
||||
path: artifacts/boot.log
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Transfer, Write and Verify Kernel
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
kernel-transfer:
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Transfer Kernel, write SPI flash and verify read-back
|
||||
run: |
|
||||
/usr/bin/python3 deployment/serial/serial_command.py transfer-kernel
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Transfer, Write and Verify RootFS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rootfs-transfer:
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect flash, transfer RootFS and verify read-back
|
||||
run: |
|
||||
/usr/bin/python3 deployment/serial/serial_command.py transfer-rootfs
|
||||
@@ -13,4 +13,4 @@ jobs:
|
||||
|
||||
- name: Check current U-Boot CLI prompt
|
||||
run: |
|
||||
/usr/bin/python3 scripts/serial_command.py check
|
||||
/usr/bin/python3 deployment/serial/serial_command.py check
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Software test - Amin Pardaz Asia v1.5
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- flash-aminpa-V1.5.yml
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ping-device:
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' || gitea.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: serial-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout tested revision
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.event.workflow_run.head_sha || gitea.sha }}
|
||||
|
||||
- name: Wait up to three minutes for device ping
|
||||
env:
|
||||
TEST_INTERFACE: ${{ vars.TEST_INTERFACE || 'eno1' }}
|
||||
TEST_HOST_IP: ${{ vars.TEST_HOST_IP || '192.168.75.15' }}
|
||||
TEST_DEVICE_IP: ${{ vars.TEST_DEVICE_IP }}
|
||||
run: |
|
||||
/usr/bin/python3 deployment/serial/software_test.py --interface "$TEST_INTERFACE" --host-ip "$TEST_HOST_IP" --device-ip "$TEST_DEVICE_IP"
|
||||
@@ -12,5 +12,8 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure network and verify U-Boot commands
|
||||
env:
|
||||
UBOOT_DEVICE_IP: ${{ vars.UBOOT_DEVICE_IP || '192.168.75.100' }}
|
||||
TFTP_SERVER_IP: ${{ vars.TFTP_SERVER_IP || '192.168.75.247' }}
|
||||
run: |
|
||||
/usr/bin/python3 scripts/serial_command.py configure
|
||||
/usr/bin/python3 deployment/serial/serial_command.py configure --device-ip "$UBOOT_DEVICE_IP" --tftp-server-ip "$TFTP_SERVER_IP"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Transfer U-Boot to RAM
|
||||
name: Transfer, Write and Verify U-Boot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Transfer firmware and verify RAM contents
|
||||
- name: Transfer firmware, write SPI flash and verify read-back
|
||||
run: |
|
||||
/usr/bin/python3 scripts/serial_command.py transfer
|
||||
/usr/bin/python3 deployment/serial/serial_command.py transfer
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# فلش و تست دستگاه Amin Pardaz Asia v1.5
|
||||
|
||||
workflow اصلی `flash-aminpa-V1.5.yml` با push به شاخهٔ `main` اجرا میشود.
|
||||
workflow تست نرمافزاری برای اجرا پس از موفقیت آن و همچنین اجرای دستی تعریف شده است.
|
||||
|
||||
## متغیرهای Gitea برای شبکهٔ U-Boot و TFTP
|
||||
|
||||
این متغیرها را در تنظیمات مخزن، بخش **Actions → Variables** تعریف کنید.
|
||||
workflow تنظیم شبکه و workflow اصلی فلش از آنها استفاده میکنند.
|
||||
|
||||
| نام متغیر | کاربرد | پیشفرض |
|
||||
| --- | --- | --- |
|
||||
| `UBOOT_DEVICE_IP` | IP دستگاه در U-Boot؛ مقدار `ipaddr` | `192.168.75.100` |
|
||||
| `TFTP_SERVER_IP` | IP سرور TFTP؛ مقدار `serverip` و مقصد ping بررسی شبکه | `192.168.75.247` |
|
||||
|
||||
هر دو مقدار باید IPv4 معتبر و بدون CIDR باشند. اگر متغیر تعریف نشود،
|
||||
مقدار پیشفرض استفاده میشود. مقدار تنظیمشده روی برد بازخوانی و بررسی میشود.
|
||||
`UBOOT_DEVICE_IP` مربوط به مرحلهٔ فلش است؛ `TEST_DEVICE_IP` مربوط به دستگاه
|
||||
پس از بوت سیستمعامل است و میتواند متفاوت باشد.
|
||||
|
||||
نمونهٔ اجرای مستقیم:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 deployment/serial/serial_command.py configure \
|
||||
--device-ip 192.168.75.100 \
|
||||
--tftp-server-ip 192.168.75.247
|
||||
```
|
||||
|
||||
## متغیرهای Gitea برای تست نرمافزاری
|
||||
|
||||
متغیرها را در تنظیمات مخزن Gitea، بخش **Actions → Variables** تعریف کنید.
|
||||
نام متغیرها باید دقیقاً مطابق جدول باشد.
|
||||
|
||||
| نام متغیر | کاربرد | پیشفرض | الزامی |
|
||||
| --- | --- | --- | --- |
|
||||
| `TEST_INTERFACE` | نام کارت شبکهٔ میزبان runner برای تست | `eno1` | خیر |
|
||||
| `TEST_HOST_IP` | آدرس IPv4 میزبان runner روی کارت شبکهٔ تست | `192.168.75.15` | خیر |
|
||||
| `TEST_DEVICE_IP` | آدرس IPv4 دستگاه فلششده که باید به ping پاسخ دهد | ندارد | بله |
|
||||
|
||||
`TEST_HOST_IP` آدرس میزبان است، نه دستگاه هدف. مقدار `TEST_DEVICE_IP` باید
|
||||
با آن متفاوت باشد و با IP دستگاه پس از بوت سیستمعامل مطابقت داشته باشد؛
|
||||
این مقدار لزوماً همان `ipaddr` موقت در U-Boot نیست.
|
||||
|
||||
IP میزبان باید از قبل روی کارت شبکهٔ انتخابشده تنظیم شده باشد و مسیر رسیدن
|
||||
به دستگاه از همان کارت شبکه عبور کند. اسکریپت تنظیمات شبکهٔ میزبان را تغییر نمیدهد.
|
||||
مقادیر IP بهصورت آدرس IPv4 وارد میشوند، نه رنج یا CIDR.
|
||||
|
||||
اسکریپت با پایتون سیستم و ابزارهای سیستم `ip` و `ping` اجرا میشود.
|
||||
پس از بررسی تنظیمات میزبان، حداکثر ۱۸۰ ثانیه منتظر پاسخ ping دستگاه میماند؛
|
||||
اولین پاسخ موفق نتیجهٔ `PASS` دارد. نبود پاسخ تا پایان مهلت یا خطای تنظیمات
|
||||
باعث `FAIL` و شکست workflow میشود.
|
||||
|
||||
نمونهٔ اجرای دستی روی runner با جایگزینکردن IP واقعی دستگاه:
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 deployment/serial/software_test.py \
|
||||
--interface eno1 \
|
||||
--host-ip 192.168.75.15 \
|
||||
--device-ip <DEVICE_IP>
|
||||
```
|
||||
|
||||
برای محل فایلهای firmware و نقشهٔ فلش، [راهنمای firmware](firmware/README.md) را ببینید.
|
||||
|
||||
## ساختار استقرار
|
||||
|
||||
اسکریپتهای اجرایی در `deployment/serial/` قرار دارند:
|
||||
|
||||
- `serial_command.py`: کنترل U-Boot، فلش و راهاندازی برد از طریق سریال.
|
||||
- `software_test.py`: بررسی پاسخ ping دستگاه پس از بوت.
|
||||
|
||||
workflowهای اصلی امینپرداز و ورودیهای workflowهای کمکی در
|
||||
`.gitea/workflows/` باقی میمانند و اسکریپتها را از مسیر جدید اجرا میکنند.
|
||||
فایلهای باینری همچنان در پوشهٔ `firmware/` ریشهٔ مخزن قرار میگیرند.
|
||||
|
||||
## تست ترتیب بوت از طریق Serial
|
||||
|
||||
workflow دستی `init test` از فایل `.gitea/workflows/init-test.yml` اجرا میشود.
|
||||
رمز دستگاه را با نام `RUTBS_PASSWORD` در **Actions → Secrets** مخزن تعریف کنید؛
|
||||
رمز در سورس یا متغیرهای عمومی قرار نمیگیرد. رانر به `pyserial` و دسترسی
|
||||
به پورت `/dev/ttyUSB0` نیاز دارد.
|
||||
|
||||
برای اجرای مستقیم، رمز را بدون نمایش در ترمینال دریافت کنید:
|
||||
|
||||
```bash
|
||||
read -rs -p 'RUTBS password: ' RUTBS_PASSWORD
|
||||
export RUTBS_PASSWORD
|
||||
python3 deployment/serial/init_test.py
|
||||
unset RUTBS_PASSWORD
|
||||
```
|
||||
|
||||
برای پورت دیگر، گزینهٔ `--port /dev/ttyUSB1` را اضافه کنید.
|
||||
برای مشاهدهٔ زندهٔ خروجی Serial با حذف رمز، گزینهٔ `--verbose` یا `-v` را اضافه کنید:
|
||||
|
||||
```bash
|
||||
python3 deployment/serial/init_test.py --verbose
|
||||
```
|
||||
|
||||
هنگام اجرای تست، برد باید در پرامپت U-Boot یعنی `=>` باشد.
|
||||
اسکریپت ابتدا با ارسال Enter پرامپت را تازه میکند و پس از تشخیص آن،
|
||||
دستور `res` را برای ریبوت میفرستد؛ سپس مراحل بوت را بهترتیب بررسی میکند.
|
||||
این تست باید پیامهای انتخاب سطح دیباگ و فعالسازی کنسول را ببیند و
|
||||
با workflow دیگری که همان پورت Serial را استفاده میکند همزمان اجرا نشود.
|
||||
از شروع تست، مهلت مشترک مراحل ۱۸۰ ثانیه است. تنها پس از ورود و دریافت
|
||||
خروجی دقیق `Linux` از `uname -s`، پیام `BOOT TEST: PASS` و کد خروج صفر
|
||||
برگردانده میشود. خطا کد خروج غیرصفر دارد. لاگ Serial با حذف رمز در
|
||||
`artifacts/boot.log` ذخیره و حتی در شکست تست بهصورت آرتیفکت بارگذاری میشود.
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Restart RUTBS from U-Boot and verify the ordered boot and kernel name."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
STAGES = (
|
||||
('U-Boot prompt', '=>', b'res\r'),
|
||||
('kernel', 'Starting kernel ...', None),
|
||||
('debug selection', 'Press the [1], [2], [3] or [4] key and hit [enter] to select the debug level', None),
|
||||
('console activation', 'Please press Enter to activate this console.', b'\r'),
|
||||
('login', 'RUTBS login:', b'root\r'),
|
||||
('password', 'Password:', 'password'),
|
||||
('shell', 'root@RUTBS:~#', b'uname -s\r'),
|
||||
('command result', 'root@RUTBS:~#', None),
|
||||
)
|
||||
FAILURE = re.compile(r'Kernel panic|Login incorrect|Login failed|Authentication fail(?:ed|ure)|Access denied', re.I)
|
||||
|
||||
|
||||
def run_test(port, password, transcript, deadline):
|
||||
stage = 0
|
||||
pending = ''
|
||||
observed = ''
|
||||
# Request a fresh prompt when the existing => predates opening the port.
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise RuntimeError('Overall test timeout')
|
||||
port.write_timeout = min(2, remaining)
|
||||
if port.write(b'\r') != 1:
|
||||
raise RuntimeError('Incomplete serial write')
|
||||
while time.monotonic() < deadline:
|
||||
data = port.read(min(port.in_waiting or 1, 4096))
|
||||
if not data:
|
||||
continue
|
||||
transcript.extend(data)
|
||||
chunk = data.decode('utf-8', errors='replace').replace('\r', '')
|
||||
pending += chunk
|
||||
observed = (observed + chunk)[-8192:]
|
||||
if FAILURE.search(observed):
|
||||
raise RuntimeError('Boot or authentication failure detected')
|
||||
while stage < len(STAGES):
|
||||
name, token, action = STAGES[stage]
|
||||
if stage == 0:
|
||||
match = re.search(r'(?:^|\n)[^\n]*=> *$', pending)
|
||||
position = pending.rfind('=>') if match else -1
|
||||
else:
|
||||
position = pending.find(token)
|
||||
if position < 0:
|
||||
break
|
||||
before = pending[:position]
|
||||
pending = pending[position + len(token):]
|
||||
if stage == len(STAGES) - 1:
|
||||
lines = [line.strip() for line in before.splitlines() if line.strip()]
|
||||
if lines and lines[0] == 'uname -s':
|
||||
lines.pop(0) # A terminal may echo the command.
|
||||
if lines != ['Linux']:
|
||||
raise RuntimeError('uname -s output was not exactly Linux')
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError('Overall test timeout')
|
||||
return
|
||||
if action is not None:
|
||||
# Discard pre-existing input: the next prompt must follow our write.
|
||||
pending = ''
|
||||
payload = password.encode('utf-8') + b'\r' if action == 'password' else action
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise RuntimeError('Overall test timeout')
|
||||
port.write_timeout = min(2, remaining)
|
||||
if port.write(payload) != len(payload):
|
||||
raise RuntimeError('Incomplete serial write')
|
||||
stage += 1
|
||||
if len(pending) > 65536:
|
||||
raise RuntimeError('Unexpected excessive serial output in current stage')
|
||||
raise RuntimeError('Timeout waiting for ' + STAGES[stage][0])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', default='/dev/ttyUSB0')
|
||||
args = parser.parse_args()
|
||||
deadline = time.monotonic() + 180
|
||||
password = os.environ.get('RUTBS_PASSWORD', '')
|
||||
transcript = bytearray()
|
||||
result = 1
|
||||
reason = 'Serial connection or configuration error'
|
||||
log = Path('artifacts/boot.log')
|
||||
try:
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Never put raw serial bytes on disk or stdout, even during authentication.
|
||||
log.write_text('', encoding='utf-8')
|
||||
if not password or any(c in password for c in '\r\n\x00'):
|
||||
raise RuntimeError('RUTBS_PASSWORD is missing or invalid')
|
||||
with open('/tmp/uboot-serial.lock', 'w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with serial.Serial(args.port, 115200, timeout=0.1, write_timeout=2, exclusive=True) as port:
|
||||
run_test(port, password, transcript, deadline)
|
||||
result = 0
|
||||
except RuntimeError as exc:
|
||||
reason = str(exc)
|
||||
except Exception:
|
||||
# Serial/library exceptions can include transmitted data; do not print them.
|
||||
pass
|
||||
finally:
|
||||
content = transcript.decode('utf-8', errors='replace').replace('\r', '')
|
||||
if password:
|
||||
content = content.replace(password, '[REDACTED]')
|
||||
reason = reason.replace(password, '[REDACTED]')
|
||||
try:
|
||||
log.write_text(content, encoding='utf-8')
|
||||
except OSError:
|
||||
result = 1
|
||||
reason = 'Could not save serial log'
|
||||
if result:
|
||||
print('BOOT TEST: FAIL')
|
||||
print(reason)
|
||||
else:
|
||||
print('BOOT TEST: PASS')
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Check an existing U-Boot prompt or configure its volatile environment."""
|
||||
import argparse
|
||||
import ipaddress
|
||||
import fcntl
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
|
||||
# Match the complete final line, never a prompt embedded in command output.
|
||||
PROMPT = re.compile(r"(?:^|\n)([^\r\n]*=>) ?$")
|
||||
ERROR = re.compile(
|
||||
r"Unknown command|## Error|^Error:|^Usage:|^.*(?:not defined|not found|failed|failure).*$",
|
||||
re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
class UBoot:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.prompt = None
|
||||
|
||||
def read_prompt(self, timeout):
|
||||
output = ""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
data = self.port.read(self.port.in_waiting or 1)
|
||||
if data:
|
||||
chunk = data.decode("utf-8", errors="replace").replace("\r", "")
|
||||
print(chunk, end="", flush=True)
|
||||
output += chunk
|
||||
match = PROMPT.search(output)
|
||||
if match and (self.prompt is None or match[1] == self.prompt):
|
||||
# Wait for a quiet prompt rather than a partial output line.
|
||||
time.sleep(0.15)
|
||||
if self.port.in_waiting:
|
||||
continue
|
||||
return output, match[1]
|
||||
raise RuntimeError("Timed out waiting for the CLI prompt")
|
||||
|
||||
def detect(self):
|
||||
# Observe before requesting a fresh prompt.
|
||||
try:
|
||||
_, candidate = self.read_prompt(3)
|
||||
except RuntimeError:
|
||||
candidate = None
|
||||
if candidate is None:
|
||||
# An idle prompt may have been printed before the port was opened.
|
||||
# A newline only requests a fresh prompt; no U-Boot command is sent.
|
||||
self.port.write(b"\r")
|
||||
self.port.flush()
|
||||
_, candidate = self.read_prompt(5)
|
||||
if not candidate.endswith("=>"):
|
||||
raise RuntimeError("Response is not a recognized U-Boot CLI prompt")
|
||||
self.prompt = candidate
|
||||
print("\nU-Boot-style CLI prompt detected: " + candidate, flush=True)
|
||||
|
||||
def command(self, command, expected=None, timeout=10, allow_missing=()):
|
||||
print("\n>>> " + command, flush=True)
|
||||
self.port.write((command + "\r").encode())
|
||||
self.port.flush()
|
||||
output, _ = self.read_prompt(timeout)
|
||||
# Query immediately, before another command changes the return status.
|
||||
self.port.write(b"echo __UBOOT_STATUS_ $?\r")
|
||||
self.port.flush()
|
||||
status, _ = self.read_prompt(5)
|
||||
codes = re.findall(r"(?m)^__UBOOT_STATUS_ (\d+) *$", status)
|
||||
# Only the initial environment inspection may report unset variables.
|
||||
missing_pattern = r'^## Error: "(' + "|".join(map(re.escape, allow_missing)) + r')" not defined$'
|
||||
missing = re.findall(missing_pattern, output, re.MULTILINE) if allow_missing else []
|
||||
checked_output = re.sub(missing_pattern, "", output, flags=re.MULTILINE) if missing else output
|
||||
if codes != ["0"] and not (codes == ["1"] and missing):
|
||||
raise RuntimeError("Command failed or its exit status could not be verified: " + command)
|
||||
if ERROR.search(checked_output):
|
||||
raise RuntimeError("Error in command response: " + command)
|
||||
if expected and not re.search(expected, output, re.MULTILINE):
|
||||
raise RuntimeError("Expected response missing: " + command)
|
||||
if missing:
|
||||
print("\nInitially unset: " + ", ".join(missing), flush=True)
|
||||
print("\nVerified: " + command, flush=True)
|
||||
return output
|
||||
|
||||
|
||||
LAYOUTS = {
|
||||
16: {"uboot": (0, 0x100000), "dtb": (0x100000, 0x10000),
|
||||
"kernel": (0x110000, 0x500000), "rootfs": (0x610000, 0x900000)},
|
||||
32: {"uboot": (0, 0x200000), "dtb": (0x200000, 0x110000),
|
||||
"kernel": (0x310000, 0x500000), "rootfs": (0x810000, 0x17f0000)},
|
||||
}
|
||||
|
||||
|
||||
def detect_flash(cli):
|
||||
output = cli.command("sf probe 0:0", r"^SF: Detected .+", timeout=20)
|
||||
capacities = re.findall(r"^SF: Detected .*\btotal (\d+) MiB\s*$", output, re.MULTILINE)
|
||||
if len(capacities) != 1 or int(capacities[0]) not in LAYOUTS:
|
||||
raise RuntimeError("Unsupported or ambiguous flash capacity; expected 16 or 32 MiB")
|
||||
capacity = int(capacities[0])
|
||||
print(f"\nDetected {capacity} MiB ({capacity * 8} Mbit); selecting matching flash layout.")
|
||||
return capacity
|
||||
|
||||
|
||||
def transfer(cli, firmware, *, dtb=False, kernel=False, rootfs=False, rootfs_size=None):
|
||||
capacity = detect_flash(cli)
|
||||
part = "rootfs" if rootfs else "kernel" if kernel else "dtb" if dtb else "uboot"
|
||||
offset, limit = LAYOUTS[capacity][part]
|
||||
address = 0x41000000 if (dtb or rootfs) else 0x42000000
|
||||
dump_size = 64 if (kernel or rootfs) else 32
|
||||
filename = {"uboot": "u-boot-sunxi-with-spl.bin", "dtb": "sun8i-v3s-licheepi-zero-dock.dtb",
|
||||
"kernel": "zImage", "rootfs": "rootfs.squashfs"}[part]
|
||||
if rootfs_size is not None and rootfs:
|
||||
if not 64 <= rootfs_size <= limit:
|
||||
raise RuntimeError("RootFS size override exceeds the selected partition or is invalid")
|
||||
limit = rootfs_size
|
||||
print(f"Selected {part}: flash offset {offset:#x}, maximum size {limit:#x}")
|
||||
image = firmware.read_bytes()
|
||||
if not dump_size <= len(image) <= limit:
|
||||
raise RuntimeError(f"Firmware size must be between {dump_size} and {limit:#x} bytes")
|
||||
if rootfs and len(image) > 0x1800000:
|
||||
raise RuntimeError("RootFS exceeds the non-overlapping RAM buffer capacity (0x1800000)")
|
||||
# Invalidate any previous transfer length before attempting a new transfer.
|
||||
cli.command("setenv filesize 0")
|
||||
cli.command("printenv filesize", r"^filesize=0$")
|
||||
output = cli.command(
|
||||
f"tftp {address:#x} {filename}",
|
||||
r"^Bytes transferred = \d+", timeout=120,
|
||||
)
|
||||
transferred = re.findall(r"^Bytes transferred = (\d+)", output, re.MULTILINE)
|
||||
# TEMPORARY: accept the TFTP image even when the checkout has another version.
|
||||
# if transferred != [str(len(image))]:
|
||||
# raise RuntimeError("TFTP byte count differs from the local firmware file")
|
||||
if len(transferred) != 1:
|
||||
raise RuntimeError("Missing or ambiguous TFTP byte count")
|
||||
transfer_size = int(transferred[0])
|
||||
if not dump_size <= transfer_size <= limit:
|
||||
raise RuntimeError("TFTP image size exceeds the selected partition or is invalid")
|
||||
if rootfs and transfer_size > 0x1800000:
|
||||
raise RuntimeError("TFTP RootFS exceeds the non-overlapping RAM buffer capacity")
|
||||
print(f"Using TFTP image: {transfer_size} bytes; local image identity check disabled.")
|
||||
output = cli.command("echo ${filesize}", r"^[0-9a-fA-F]+$")
|
||||
sizes = re.findall(r"^([0-9a-fA-F]+)$", output, re.MULTILINE)
|
||||
if len(sizes) != 1 or int(sizes[0], 16) != transfer_size:
|
||||
raise RuntimeError("U-Boot filesize differs from the TFTP transfer byte count")
|
||||
output = cli.command(f"md.b {address:#x} {dump_size:#x}", rf"^{address:08x}:")
|
||||
dumped = bytearray()
|
||||
for row_offset in range(0, dump_size, 16):
|
||||
row_address = f"{address + row_offset:08x}"
|
||||
rows = re.findall(r"^" + row_address + r":((?: [0-9a-fA-F]{2}){16})(?:\s|$)", output, re.MULTILINE)
|
||||
if len(rows) != 1:
|
||||
raise RuntimeError("Missing or invalid memory dump at " + row_address)
|
||||
dumped.extend(bytes.fromhex(rows[0]))
|
||||
# TEMPORARY: the TFTP image may also have a different header from the checkout.
|
||||
# if dumped != image[:dump_size]:
|
||||
# raise RuntimeError(f"First {dump_size} bytes in RAM differ from the local firmware file")
|
||||
print("\nTransfer size verified against U-Boot filesize; RAM header read successfully.")
|
||||
cli.command(
|
||||
f"sf write {address:#x} {offset:#x} ${{filesize}}",
|
||||
rf"^SF: {transfer_size} bytes @ {offset:#x} Written: OK\s*$", timeout=120,
|
||||
)
|
||||
cli.command(
|
||||
f"sf read 0x42800000 {offset:#x} ${{filesize}}",
|
||||
rf"^SF: {transfer_size} bytes @ {offset:#x} Read: OK\s*$", timeout=120,
|
||||
)
|
||||
output = cli.command(
|
||||
f"cmp.b {address:#x} 0x42800000 ${{filesize}}",
|
||||
r"^Total of \d+ byte\(s\) were the same", timeout=60,
|
||||
)
|
||||
compared = re.findall(r"^Total of (\d+) byte\(s\) were the same", output, re.MULTILINE)
|
||||
if compared != [str(transfer_size)]:
|
||||
raise RuntimeError("Flash comparison byte count differs from firmware size")
|
||||
print("\nFirmware flash write and full read-back comparison verified.")
|
||||
|
||||
|
||||
def erase_flash(cli):
|
||||
capacity = detect_flash(cli)
|
||||
length = capacity * 1024 * 1024
|
||||
cli.command(
|
||||
f"sf erase 0x0 {length:#x}",
|
||||
rf"^SF: {length} bytes @ 0x0 Erased: OK\s*$", timeout=600,
|
||||
)
|
||||
cli.command(
|
||||
"sf read 0x42800000 0x0 0x100",
|
||||
r"^SF: 256 bytes @ 0x0 Read: OK\s*$", timeout=20,
|
||||
)
|
||||
output = cli.command("md.b 0x42800000 0x100", r"^42800000:")
|
||||
for offset in range(0, 0x100, 16):
|
||||
address = f"{0x42800000 + offset:08x}"
|
||||
rows = re.findall(
|
||||
r"^" + address + r":((?: [0-9a-fA-F]{2}){16})(?:\s|$)",
|
||||
output, re.MULTILINE,
|
||||
)
|
||||
if len(rows) != 1 or bytes.fromhex(rows[0]) != b"\xff" * 16:
|
||||
raise RuntimeError("Erase verification failed at " + address)
|
||||
print("\nFull-flash erase command succeeded; first 256 bytes verified as FF.")
|
||||
|
||||
|
||||
def set_bootcmd(cli):
|
||||
capacity = detect_flash(cli)
|
||||
dtb_offset, dtb_size = LAYOUTS[capacity]["dtb"]
|
||||
kernel_offset, kernel_size = LAYOUTS[capacity]["kernel"]
|
||||
bootcmd = (
|
||||
f"sf probe 0; sf read 0x41800000 {dtb_offset:#x} {dtb_size:#x}; "
|
||||
f"sf read 0x42000000 {kernel_offset:#x} {kernel_size:#x}; "
|
||||
"bootz 0x42000000 - 0x41800000"
|
||||
)
|
||||
cli.command("setenv bootcmd '" + bootcmd + "'")
|
||||
cli.command("printenv bootcmd", r"^bootcmd=" + re.escape(bootcmd) + r"$")
|
||||
print("\nDynamic bootcmd verified in RAM; environment not saved and bootcmd not executed.")
|
||||
|
||||
|
||||
def boot_config(cli, confirmed=False):
|
||||
cli.command(
|
||||
"printenv bootcmd bootargs", allow_missing=("bootcmd", "bootargs"),
|
||||
)
|
||||
if not confirmed:
|
||||
print("\nBoot configuration inspected; no changes requested.")
|
||||
return
|
||||
bootargs = "console=ttyS0,115200 loglevel=1 root=/dev/mtdblock3 rootfstype=squashfs ro rootwait"
|
||||
cli.command("setenv bootargs '" + bootargs + "'")
|
||||
cli.command("printenv bootargs", r"^bootargs=" + re.escape(bootargs) + r"$")
|
||||
print("\nBootargs verified in RAM; environment not saved and board not rebooted.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=("check", "configure", "transfer", "transfer-dtb", "transfer-kernel", "transfer-rootfs", "erase", "boot-config", "preflight", "set-bootcmd", "saveenv", "reboot"), default="check", nargs="?")
|
||||
parser.add_argument("--port", default="/dev/ttyUSB0")
|
||||
parser.add_argument("--firmware", type=Path, default=None)
|
||||
parser.add_argument("--rootfs-size", type=lambda value: int(value, 0), help="Actual RootFS partition size in bytes (decimal or 0x-prefixed hex)")
|
||||
parser.add_argument("--confirm-squashfs-mtdblock3", action="store_true", help="Confirm actual RootFS is SquashFS on mtdblock3 and set bootargs")
|
||||
parser.add_argument("--device-ip", type=ipaddress.IPv4Address, default="192.168.75.100")
|
||||
parser.add_argument("--tftp-server-ip", type=ipaddress.IPv4Address, default="192.168.75.247")
|
||||
args = parser.parse_args()
|
||||
if args.firmware is None:
|
||||
filename = "rootfs.squashfs" if args.mode == "transfer-rootfs" else "zImage" if args.mode == "transfer-kernel" else ("sun8i-v3s-licheepi-zero-dock.dtb" if args.mode == "transfer-dtb" else "u-boot-sunxi-with-spl.bin")
|
||||
args.firmware = Path(__file__).resolve().parents[2] / "firmware" / filename
|
||||
if args.mode in ("transfer", "transfer-dtb", "transfer-kernel", "transfer-rootfs") and not args.firmware.is_file():
|
||||
raise RuntimeError("Firmware file not found: " + str(args.firmware))
|
||||
# Shared by both workflows: fail rather than interleave serial commands.
|
||||
with open("/tmp/uboot-serial.lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with serial.Serial(args.port, 115200, timeout=0.1, write_timeout=2, exclusive=True) as port:
|
||||
cli = UBoot(port)
|
||||
cli.detect()
|
||||
if args.mode == "check":
|
||||
return
|
||||
if args.mode == "saveenv":
|
||||
cli.command("saveenv", timeout=120)
|
||||
print("Current U-Boot environment saved successfully.", flush=True)
|
||||
return
|
||||
if args.mode == "reboot":
|
||||
print("\n>>> reset", flush=True)
|
||||
port.write(b"reset\r")
|
||||
port.flush()
|
||||
print("Reset command sent; reboot completion has not been verified.", flush=True)
|
||||
return
|
||||
if args.mode == "preflight":
|
||||
capacity = detect_flash(cli)
|
||||
files = {"uboot": "u-boot-sunxi-with-spl.bin", "dtb": "sun8i-v3s-licheepi-zero-dock.dtb",
|
||||
"kernel": "zImage", "rootfs": "rootfs.squashfs"}
|
||||
for part, filename in files.items():
|
||||
path = Path(__file__).resolve().parents[2] / "firmware" / filename
|
||||
minimum = 64 if part in ("kernel", "rootfs") else 32
|
||||
limit = LAYOUTS[capacity][part][1]
|
||||
if not path.is_file() or not minimum <= path.stat().st_size <= limit:
|
||||
raise RuntimeError(f"Firmware missing or outside selected partition size: {path} (maximum {limit:#x})")
|
||||
print("All firmware sizes fit the detected flash layout.")
|
||||
return
|
||||
if args.mode == "erase":
|
||||
erase_flash(cli)
|
||||
return
|
||||
if args.mode == "boot-config":
|
||||
boot_config(cli, confirmed=args.confirm_squashfs_mtdblock3)
|
||||
return
|
||||
if args.mode == "set-bootcmd":
|
||||
set_bootcmd(cli)
|
||||
return
|
||||
if args.mode in ("transfer", "transfer-dtb", "transfer-kernel", "transfer-rootfs"):
|
||||
transfer(cli, args.firmware, dtb=args.mode == "transfer-dtb", kernel=args.mode == "transfer-kernel", rootfs=args.mode == "transfer-rootfs", rootfs_size=args.rootfs_size)
|
||||
return
|
||||
cli.command("version", r"^U-Boot \d")
|
||||
cli.command(
|
||||
"printenv ipaddr serverip ethaddr bootcmd bootargs",
|
||||
allow_missing=("ipaddr", "serverip", "ethaddr", "bootcmd", "bootargs"),
|
||||
)
|
||||
cli.command(f"setenv ipaddr {args.device_ip}")
|
||||
cli.command("printenv ipaddr", r"^ipaddr=" + re.escape(str(args.device_ip)) + r"$")
|
||||
cli.command(f"setenv serverip {args.tftp_server_ip}")
|
||||
cli.command("printenv serverip", r"^serverip=" + re.escape(str(args.tftp_server_ip)) + r"$")
|
||||
cli.command(f"ping {args.tftp_server_ip}", r"^host " + re.escape(str(args.tftp_server_ip)) + r" is alive$", timeout=30)
|
||||
cli.command("sf probe 0:0", r"^SF: Detected .+", timeout=20)
|
||||
print("\nAll commands verified; environment changes were not saved to flash.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, OSError, serial.SerialException) as exc:
|
||||
print("\nERROR: " + str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Wait up to three minutes for the flashed device to answer ICMP."""
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def test_device(interface, host_ip, device_ip):
|
||||
host = str(ipaddress.IPv4Address(host_ip))
|
||||
device = str(ipaddress.IPv4Address(device_ip))
|
||||
if host == device:
|
||||
raise ValueError('Device IP must differ from the test host IP')
|
||||
if not interface or interface.startswith('-'):
|
||||
raise ValueError('Invalid network interface')
|
||||
result = subprocess.run(
|
||||
['ip', '-j', '-4', 'addr', 'show', 'dev', interface],
|
||||
check=True, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
addresses = json.loads(result.stdout)
|
||||
if not any(info.get('local') == host for entry in addresses for info in entry.get('addr_info', [])):
|
||||
raise ValueError(f'Host IP {host} is not configured on {interface}')
|
||||
result = subprocess.run(
|
||||
['ip', '-j', '-4', 'route', 'get', device, 'from', host],
|
||||
check=True, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
routes = json.loads(result.stdout)
|
||||
if not routes or any(route.get('dev') != interface for route in routes):
|
||||
raise ValueError(f'Route to {device} does not use {interface}')
|
||||
print(f'Waiting up to 180 seconds: {host} ({interface}) -> {device}', flush=True)
|
||||
# -c 1 exits on the first reply; -w bounds the entire ping run.
|
||||
result = subprocess.run(
|
||||
['ping', '-4', '-n', '-B', '-I', host, '-c', '1', '-i', '1', '-W', '1', '-w', '180', device],
|
||||
timeout=180,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'FAIL: {device} did not pass the ping test (exit {result.returncode})')
|
||||
print(f'PASS: device {device} answered ping', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--interface', default='eno1')
|
||||
parser.add_argument('--host-ip', default='192.168.75.15')
|
||||
parser.add_argument('--device-ip', required=True)
|
||||
args = parser.parse_args()
|
||||
test_device(args.interface, args.host_ip, args.device_ip)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
||||
print(f'FAIL: {exc}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
+27
-8
@@ -1,10 +1,29 @@
|
||||
Place `u-boot-sunxi-with-spl.bin` in this directory before running the
|
||||
Transfer U-Boot to RAM workflow.
|
||||
Place firmware files here and serve this directory as the existing TFTP
|
||||
server root. Checkout alone does not publish files over TFTP.
|
||||
|
||||
The existing TFTP server (`serverip` in U-Boot) must serve this directory
|
||||
as its TFTP root, so the file is available as `u-boot-sunxi-with-spl.bin`.
|
||||
Checking out this repository alone does not publish files over TFTP.
|
||||
Every transfer starts with sf probe 0:0. The reported total capacity must
|
||||
be exactly 16 or 32 MiB; an unknown or ambiguous result stops execution.
|
||||
The selected offsets and maximum sizes (hex bytes) are:
|
||||
|
||||
The workflow uses the current U-Boot network settings. It verifies the
|
||||
transferred size and the first 32 bytes against this local file. It does
|
||||
not erase or write SPI flash, save the environment, or boot the image.
|
||||
| File | 16 MiB offset / maximum | 32 MiB offset / maximum |
|
||||
| --- | --- | --- |
|
||||
| u-boot-sunxi-with-spl.bin | 0 / 0x100000 | 0 / 0x200000 |
|
||||
| sun8i-v3s-licheepi-zero-dock.dtb | 0x100000 / 0x10000 | 0x200000 / 0x110000 |
|
||||
| zImage | 0x110000 / 0x500000 | 0x310000 / 0x500000 |
|
||||
| rootfs.squashfs | 0x610000 / 0x900000 | 0x810000 / 0x17f0000 |
|
||||
|
||||
The 32 MiB layout allocates the remainder of flash to RootFS. These are
|
||||
selected project layouts, not partition tables read from the chip. DTB
|
||||
partitions and bootcmd must match the selected layout. A smaller actual
|
||||
RootFS region can be specified with --rootfs-size; larger overrides fail.
|
||||
|
||||
Transfers reset and verify filesize=0 before TFTP, require successful
|
||||
transfer output and status, and compare the hexadecimal filesize with the
|
||||
local file and transfer count. First 32 bytes (U-Boot/DTB) or 64 bytes
|
||||
(Kernel/RootFS) are compared with the local file. Writes are read back at
|
||||
0x42800000 and compared in full. No automatic erase or retry is performed
|
||||
in transfer workflows. Flash must be erased before programming as needed.
|
||||
|
||||
The separate erase workflow detects capacity and erases the entire chip,
|
||||
including firmware and saved environment. It verifies only the first 256
|
||||
bytes as FF, not every byte. No workflow automatically reboots the board.
|
||||
|
||||
دودویی (BIN)
فایل باینری نشان داده نشده است.
دودویی (BIN)
فایل باینری نشان داده نشده است.
دودویی (BIN)
فایل باینری نشان داده نشده است.
Executable
دودویی (BIN)
فایل باینری نشان داده نشده است.
@@ -1,150 +0,0 @@
|
||||
"""Check an existing U-Boot prompt or configure its volatile environment."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
|
||||
# Match the complete final line, never a prompt embedded in command output.
|
||||
PROMPT = re.compile(r"(?:^|\n)([^\r\n]*=>) ?$")
|
||||
ERROR = re.compile(
|
||||
r"Unknown command|## Error|^Error:|^Usage:|^.*(?:not defined|not found|failed|failure).*$",
|
||||
re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
class UBoot:
|
||||
def __init__(self, port):
|
||||
self.port = port
|
||||
self.prompt = None
|
||||
|
||||
def read_prompt(self, timeout):
|
||||
output = ""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
data = self.port.read(self.port.in_waiting or 1)
|
||||
if data:
|
||||
chunk = data.decode("utf-8", errors="replace").replace("\r", "")
|
||||
print(chunk, end="", flush=True)
|
||||
output += chunk
|
||||
match = PROMPT.search(output)
|
||||
if match and (self.prompt is None or match[1] == self.prompt):
|
||||
# Wait for a quiet prompt rather than a partial output line.
|
||||
time.sleep(0.15)
|
||||
if self.port.in_waiting:
|
||||
continue
|
||||
return output, match[1]
|
||||
raise RuntimeError("Timed out waiting for the CLI prompt")
|
||||
|
||||
def detect(self):
|
||||
# Observe before requesting a fresh prompt.
|
||||
try:
|
||||
_, candidate = self.read_prompt(3)
|
||||
except RuntimeError:
|
||||
candidate = None
|
||||
if candidate is None:
|
||||
# An idle prompt may have been printed before the port was opened.
|
||||
# A newline only requests a fresh prompt; no U-Boot command is sent.
|
||||
self.port.write(b"\r")
|
||||
self.port.flush()
|
||||
_, candidate = self.read_prompt(5)
|
||||
if not candidate.endswith("=>"):
|
||||
raise RuntimeError("Response is not a recognized U-Boot CLI prompt")
|
||||
self.prompt = candidate
|
||||
print("\nU-Boot-style CLI prompt detected: " + candidate, flush=True)
|
||||
|
||||
def command(self, command, expected=None, timeout=10, allow_missing=()):
|
||||
print("\n>>> " + command, flush=True)
|
||||
self.port.write((command + "\r").encode())
|
||||
self.port.flush()
|
||||
output, _ = self.read_prompt(timeout)
|
||||
# Query immediately, before another command changes the return status.
|
||||
self.port.write(b"echo __UBOOT_STATUS_ $?\r")
|
||||
self.port.flush()
|
||||
status, _ = self.read_prompt(5)
|
||||
codes = re.findall(r"(?m)^__UBOOT_STATUS_ (\d+) *$", status)
|
||||
# Only the initial environment inspection may report unset variables.
|
||||
missing_pattern = r'^## Error: "(' + "|".join(map(re.escape, allow_missing)) + r')" not defined$'
|
||||
missing = re.findall(missing_pattern, output, re.MULTILINE) if allow_missing else []
|
||||
checked_output = re.sub(missing_pattern, "", output, flags=re.MULTILINE) if missing else output
|
||||
if codes != ["0"] and not (codes == ["1"] and missing):
|
||||
raise RuntimeError("Command failed or its exit status could not be verified: " + command)
|
||||
if ERROR.search(checked_output):
|
||||
raise RuntimeError("Error in command response: " + command)
|
||||
if expected and not re.search(expected, output, re.MULTILINE):
|
||||
raise RuntimeError("Expected response missing: " + command)
|
||||
if missing:
|
||||
print("\nInitially unset: " + ", ".join(missing), flush=True)
|
||||
print("\nVerified: " + command, flush=True)
|
||||
return output
|
||||
|
||||
|
||||
def transfer(cli, firmware):
|
||||
image = firmware.read_bytes()
|
||||
if not 32 <= len(image) <= 0x02000000:
|
||||
raise RuntimeError("Firmware must contain at least 32 bytes and fit RAM at 0x42000000")
|
||||
output = cli.command(
|
||||
"tftp 0x42000000 u-boot-sunxi-with-spl.bin",
|
||||
r"^Bytes transferred = \d+", timeout=120,
|
||||
)
|
||||
transferred = re.findall(r"^Bytes transferred = (\d+)", output, re.MULTILINE)
|
||||
if transferred != [str(len(image))]:
|
||||
raise RuntimeError("TFTP byte count differs from the local firmware file")
|
||||
output = cli.command("echo ${filesize}", r"^[0-9a-fA-F]+$")
|
||||
sizes = re.findall(r"^([0-9a-fA-F]+)$", output, re.MULTILINE)
|
||||
if len(sizes) != 1 or int(sizes[0], 16) != len(image):
|
||||
raise RuntimeError("U-Boot filesize differs from the local firmware file")
|
||||
output = cli.command("md.b 0x42000000 0x20", r"^42000000:")
|
||||
dumped = bytearray()
|
||||
for address in ("42000000", "42000010"):
|
||||
rows = re.findall(r"^" + address + r":((?: [0-9a-fA-F]{2}){16})(?:\s|$)", output, re.MULTILINE)
|
||||
if len(rows) != 1:
|
||||
raise RuntimeError("Missing or invalid memory dump at " + address)
|
||||
dumped.extend(bytes.fromhex(rows[0]))
|
||||
if dumped != image[:32]:
|
||||
raise RuntimeError("First 32 bytes in RAM differ from the local firmware file")
|
||||
print("\nTransfer verified: size and first 32 bytes match; no flash write performed.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=("check", "configure", "transfer"), default="check", nargs="?")
|
||||
parser.add_argument("--port", default="/dev/ttyUSB0")
|
||||
parser.add_argument("--firmware", type=Path, default=Path(__file__).resolve().parent.parent / "firmware" / "u-boot-sunxi-with-spl.bin")
|
||||
args = parser.parse_args()
|
||||
if args.mode == "transfer" and not args.firmware.is_file():
|
||||
raise RuntimeError("Firmware file not found: " + str(args.firmware))
|
||||
# Shared by both workflows: fail rather than interleave serial commands.
|
||||
with open("/tmp/uboot-serial.lock", "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with serial.Serial(args.port, 115200, timeout=0.1, write_timeout=2, exclusive=True) as port:
|
||||
cli = UBoot(port)
|
||||
cli.detect()
|
||||
if args.mode == "check":
|
||||
return
|
||||
if args.mode == "transfer":
|
||||
transfer(cli, args.firmware)
|
||||
return
|
||||
cli.command("version", r"^U-Boot \d")
|
||||
cli.command(
|
||||
"printenv ipaddr serverip ethaddr bootcmd bootargs",
|
||||
allow_missing=("ipaddr", "serverip", "ethaddr", "bootcmd", "bootargs"),
|
||||
)
|
||||
cli.command("setenv ipaddr 192.168.75.100")
|
||||
cli.command("printenv ipaddr", r"^ipaddr=192\.168\.75\.100$")
|
||||
cli.command("setenv serverip 192.168.75.247")
|
||||
cli.command("printenv serverip", r"^serverip=192\.168\.75\.247$")
|
||||
cli.command("ping 192.168.75.247", r"^host 192\.168\.75\.247 is alive$", timeout=30)
|
||||
cli.command("sf probe 0:0", r"^SF: Detected .+", timeout=20)
|
||||
print("\nAll commands verified; environment changes were not saved to flash.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (RuntimeError, OSError, serial.SerialException) as exc:
|
||||
print("\nERROR: " + str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
مرجع در شماره جدید
مسدود کردن کاربر