Build optimization: pre-built PM3 binaries, ARM64 CI, base image caching
Replace PM3 compile-from-source in pi-gen with pre-built tarball extraction (saves 43-58 min). Merge stagePM3 into stageDangerousPi as 02-pm3-install substage, renumber all subsequent substages. Switch CI PM3 build to native ARM64 runner (ubuntu-24.04-arm64) eliminating QEMU overhead. Add weekly base-image workflow for pre-baking stages 0-2. Support PM3_TARBALL, BASE_IMAGE, and APT_PROXY env vars in build-image.sh. Also includes prior Phase 5 work: theme system, design system integration, component update system, OS updates, CI build pipeline, and test results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
77
.github/workflows/base-image.yml
vendored
Normal file
77
.github/workflows/base-image.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Build Base Image (Stages 0-2)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Weekly on Sunday at 04:00 UTC
|
||||
- cron: '0 4 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-base:
|
||||
runs-on: ubuntu-24.04-arm64
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pi-gen
|
||||
run: |
|
||||
git clone --depth 1 https://github.com/RPi-Distro/pi-gen.git /tmp/pi-gen-builder
|
||||
|
||||
- name: Configure base-only build
|
||||
run: |
|
||||
cd /tmp/pi-gen-builder
|
||||
|
||||
# Copy our config but override STAGE_LIST to only build base stages
|
||||
cp "${GITHUB_WORKSPACE}/pi-gen/config" config
|
||||
sed -i 's/^STAGE_LIST=.*/STAGE_LIST="stage0 stage1 stage2"/' config
|
||||
|
||||
# Mark stage2 for export so we get the rootfs
|
||||
touch stage2/EXPORT_IMAGE
|
||||
|
||||
- name: Build base image
|
||||
run: |
|
||||
cd /tmp/pi-gen-builder
|
||||
PIGEN_DOCKER_MODE=1 ./build-docker.sh
|
||||
|
||||
- name: Package base rootfs
|
||||
run: |
|
||||
cd /tmp/pi-gen-builder
|
||||
DATE="$(date -u +%Y%m%d)"
|
||||
|
||||
# Find the stage2 rootfs in the work directory
|
||||
ROOTFS_DIR=$(find work/ -path "*/stage2/rootfs" -type d | head -1)
|
||||
if [ -z "$ROOTFS_DIR" ]; then
|
||||
echo "ERROR: stage2 rootfs not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Packaging rootfs from: $ROOTFS_DIR"
|
||||
tar -czf "${GITHUB_WORKSPACE}/base-image-trixie-arm64-${DATE}.tar.gz" \
|
||||
-C "$(dirname "$ROOTFS_DIR")" rootfs/
|
||||
|
||||
ls -lh "${GITHUB_WORKSPACE}/base-image-trixie-arm64-${DATE}.tar.gz"
|
||||
|
||||
- name: Create base image release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
DATE="$(date -u +%Y%m%d)"
|
||||
TAG="base-v${DATE}"
|
||||
TARBALL="base-image-trixie-arm64-${DATE}.tar.gz"
|
||||
|
||||
# Delete previous base release if exists (keep only latest)
|
||||
gh release delete "$TAG" --yes 2>/dev/null || true
|
||||
git push --delete origin "$TAG" 2>/dev/null || true
|
||||
|
||||
# Create new release
|
||||
gh release create "$TAG" "$TARBALL" \
|
||||
--title "Base Image ${DATE}" \
|
||||
--notes "Pre-baked base image (stages 0-2) for Debian Trixie arm64.
|
||||
|
||||
Use with: BASE_IMAGE=./${TARBALL} ./build-image.sh full
|
||||
|
||||
Built: $(date -u +%Y-%m-%d\ %H:%M:%S\ UTC)" \
|
||||
--prerelease
|
||||
152
.github/workflows/build-release.yml
vendored
Normal file
152
.github/workflows/build-release.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
name: Build and Release Components
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
components:
|
||||
description: 'Components to build (comma-separated, or "all")'
|
||||
required: false
|
||||
default: 'all'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ------------------------------------------------------------------
|
||||
# PM3: compile natively on ARM64 runner (no QEMU overhead)
|
||||
# ------------------------------------------------------------------
|
||||
build-pm3:
|
||||
runs-on: ubuntu-24.04-arm64
|
||||
strategy:
|
||||
matrix:
|
||||
python_version: ['3.11', '3.12']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build PM3 natively on aarch64
|
||||
env:
|
||||
PYTHON_VERSION: ${{ matrix.python_version }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${GITHUB_WORKSPACE}:/workspace" \
|
||||
-e "PYTHON_VERSION=${PYTHON_VERSION}" \
|
||||
-e OUTPUT_DIR=/workspace/dist \
|
||||
-w /workspace \
|
||||
debian:trixie \
|
||||
bash ci/build-pm3.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pm3-cp${{ matrix.python_version }}
|
||||
path: dist/pm3-*.tar.gz
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Frontend: build Remix app
|
||||
# ------------------------------------------------------------------
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
chmod +x ci/build-frontend.sh
|
||||
VERSION="${REF_NAME#v}" ci/build-frontend.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend
|
||||
path: dist/frontend-*.tar.gz
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backend: package Python source
|
||||
# ------------------------------------------------------------------
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Package backend
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
chmod +x ci/build-backend.sh
|
||||
VERSION="${REF_NAME#v}" ci/build-backend.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend
|
||||
path: dist/backend-*.tar.gz
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Theme: build token CSS from design system
|
||||
# ------------------------------------------------------------------
|
||||
build-theme:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout design system
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: dangerous-tacos/dt-design-system
|
||||
path: dt-design-system
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Build theme
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
chmod +x ci/build-theme.sh
|
||||
VERSION="${REF_NAME#v}" \
|
||||
DESIGN_SYSTEM_DIR="${GITHUB_WORKSPACE}/dt-design-system" \
|
||||
ci/build-theme.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: theme
|
||||
path: dist/theme-*.tar.gz
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create release with all component assets
|
||||
# ------------------------------------------------------------------
|
||||
create-release:
|
||||
needs: [build-pm3, build-frontend, build-backend, build-theme]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist/
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate release manifest
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
python3 ci/generate-manifest.py dist/ \
|
||||
--version "${REF_NAME#v}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/release-manifest.json
|
||||
generate_release_notes: true
|
||||
282
.plan
Normal file
282
.plan
Normal file
@@ -0,0 +1,282 @@
|
||||
# Plan: APT Optimization & PM3 Build Caching
|
||||
|
||||
## Goal
|
||||
Reduce redundant network requests and unnecessary rebuilds during pi-gen image builds.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Consolidate APT operations in stageDangerousPi
|
||||
|
||||
### 1A. Create a unified package list file for stageDangerousPi
|
||||
|
||||
**File:** `pi-gen/stageDangerousPi/00-packages` (new)
|
||||
|
||||
Move all apt packages currently installed inline across substages into a single
|
||||
pi-gen `00-packages` file at the stage root level. Pi-gen's `run_sub_stage()`
|
||||
already handles these automatically — one `apt-get install` call, one
|
||||
`Reading package lists` parse.
|
||||
|
||||
**Problem:** pi-gen processes `XX-packages` files *per sub-stage directory*, not
|
||||
at the stage level. The `run_stage()` iterates over `${STAGE_DIR}/*` sub-stage
|
||||
directories, then `run_sub_stage()` looks for `{00..99}-packages` inside each.
|
||||
There is no stage-level package consolidation built in.
|
||||
|
||||
**Revised approach:** Add a new sub-stage `00-apt-setup` that runs first (before
|
||||
`01-Wireless-AP`) and handles all package installation for the entire
|
||||
stageDangerousPi in one shot.
|
||||
|
||||
Create: `pi-gen/stageDangerousPi/00-apt-setup/00-packages`
|
||||
```
|
||||
# All packages needed across stageDangerousPi substages
|
||||
# (consolidated to avoid redundant apt-get calls)
|
||||
|
||||
# 01-Wireless-AP
|
||||
hostapd
|
||||
dnsmasq
|
||||
nftables
|
||||
nginx
|
||||
|
||||
# 03-dangerous-pi
|
||||
nodejs
|
||||
npm
|
||||
lrzsz
|
||||
|
||||
# 04-pisugar
|
||||
i2c-tools
|
||||
```
|
||||
|
||||
Note: `python3-pip` is omitted because it's already present from stage2 (Python 3.13
|
||||
is installed in stage1, pip comes with it). The conditional `if ! command -v pip3`
|
||||
in 03-dangerous-pi is dead code.
|
||||
|
||||
### 1B. Remove inline `apt-get install` calls from substage scripts
|
||||
|
||||
**Files to modify:**
|
||||
|
||||
1. `pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh`
|
||||
- Remove lines 12-16 (`apt-get install -y hostapd dnsmasq nftables nginx`)
|
||||
- Add a comment: `# Packages installed by 00-apt-setup stage`
|
||||
|
||||
2. `pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh`
|
||||
- Remove lines 17-25 (the conditional pip3 check + `apt-get update` + both
|
||||
`apt-get install` calls)
|
||||
- Add a comment noting packages come from 00-apt-setup
|
||||
|
||||
3. `pi-gen/stageDangerousPi/04-pisugar/00-run-chroot.sh`
|
||||
- Remove lines 49-50 (`apt-get update` + `apt-get install -y i2c-tools`)
|
||||
- Add a comment noting i2c-tools comes from 00-apt-setup
|
||||
|
||||
### 1C. Remove the redundant `apt-get update` from stagePM3
|
||||
|
||||
**File:** `pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh`
|
||||
- Remove line 11 (`apt-get update`)
|
||||
- The metadata was just refreshed by stage0's `00-configure-apt`. Between
|
||||
stage0 and stagePM3, `copy_previous()` preserves `/var/lib/apt/lists/`
|
||||
(only `/var/cache/apt/archives` is excluded). The index is still valid.
|
||||
|
||||
**Risk assessment:** Low. The build runs in a single session — the metadata
|
||||
fetched minutes ago in stage0 hasn't changed. The build log confirms all 4
|
||||
repos returned `Hit:` (cache valid) when stagePM3 ran `apt-get update`.
|
||||
|
||||
### Estimated savings
|
||||
- 2-3 redundant `apt-get update` round-trips eliminated: ~10-15s
|
||||
- ~5 fewer `Reading package lists` parses under QEMU: ~25-40s
|
||||
- Fewer apt-get install invocations (3→0 in stageDangerousPi): ~15-20s
|
||||
- **Total: ~50-75s per build**
|
||||
|
||||
---
|
||||
|
||||
## Part 2: PM3 build caching (skip rebuild when unchanged)
|
||||
|
||||
### Problem
|
||||
The PM3 build (clone + compile firmware + client + SWIG bindings) takes
|
||||
~45-60 minutes under QEMU. Currently `from-pm3` always rebuilds it, even
|
||||
if nothing changed (same upstream commit, same patch, same build deps).
|
||||
|
||||
### Approach: Content-addressed cache key
|
||||
|
||||
Create a manifest file that captures everything that affects the PM3 build
|
||||
output. If the manifest matches what was used for the cached build, skip
|
||||
the entire PM3 compilation.
|
||||
|
||||
**Cache key inputs:**
|
||||
1. The LED PWM control patch file hash
|
||||
2. The Proxmark3 upstream git commit (pinned or HEAD)
|
||||
3. The branding sed command in the build script
|
||||
4. The build script itself (00-run-chroot.sh)
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 2A. Pin the Proxmark3 upstream commit
|
||||
|
||||
**File:** `pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh`
|
||||
|
||||
Currently clones `HEAD` of master — non-deterministic. Change to clone a
|
||||
specific tag or commit:
|
||||
|
||||
```bash
|
||||
PM3_VERSION="v4.20728" # Pin to known-good release
|
||||
PM3_COMMIT="" # Optional: specific commit hash
|
||||
|
||||
git clone https://github.com/RfidResearchGroup/proxmark3
|
||||
cd proxmark3
|
||||
if [ -n "$PM3_COMMIT" ]; then
|
||||
git checkout "$PM3_COMMIT"
|
||||
fi
|
||||
```
|
||||
|
||||
Add a small config file `pi-gen/stagePM3/pm3-version.conf`:
|
||||
```bash
|
||||
PM3_REPO="https://github.com/RfidResearchGroup/proxmark3"
|
||||
PM3_COMMIT="ef82d5ba1" # from the build log version string
|
||||
```
|
||||
|
||||
#### 2B. Generate and check a build manifest
|
||||
|
||||
**File:** `pi-gen/stagePM3/01-proxmark3/00-run.sh` (pre-chroot, runs on host)
|
||||
|
||||
Add manifest generation and comparison logic:
|
||||
|
||||
```bash
|
||||
# Generate cache key from all PM3 build inputs
|
||||
generate_pm3_manifest() {
|
||||
{
|
||||
# Hash of the build script itself
|
||||
sha256sum "${SCRIPT_DIR}/00-run-chroot.sh"
|
||||
# Hash of the LED patch
|
||||
sha256sum "${SCRIPT_DIR}/led-pwm-control.patch"
|
||||
# PM3 version config
|
||||
cat "${SCRIPT_DIR}/../pm3-version.conf" 2>/dev/null || echo "HEAD"
|
||||
} | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
MANIFEST=$(generate_pm3_manifest)
|
||||
CACHED_MANIFEST="${ROOTFS_DIR}/opt/dangerous-pi/.pm3-build-manifest"
|
||||
|
||||
if [ -f "$CACHED_MANIFEST" ] && [ "$(cat "$CACHED_MANIFEST")" = "$MANIFEST" ]; then
|
||||
echo "PM3 build manifest unchanged — skipping rebuild"
|
||||
# Create a SKIP file for the chroot script
|
||||
touch "${SCRIPT_DIR}/SKIP_PM3_BUILD"
|
||||
else
|
||||
echo "PM3 build manifest changed — will rebuild"
|
||||
echo " New: $MANIFEST"
|
||||
[ -f "$CACHED_MANIFEST" ] && echo " Old: $(cat "$CACHED_MANIFEST")"
|
||||
rm -f "${SCRIPT_DIR}/SKIP_PM3_BUILD"
|
||||
fi
|
||||
```
|
||||
|
||||
#### 2C. Honor the skip flag in the chroot build script
|
||||
|
||||
**File:** `pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh`
|
||||
|
||||
Wrap the entire build in a check:
|
||||
|
||||
```bash
|
||||
if [ -f /tmp/SKIP_PM3_BUILD ]; then
|
||||
echo "=== Skipping Proxmark3 build (cached, manifest unchanged) ==="
|
||||
# Still verify the installation is intact
|
||||
DEFAULT_USER=$(awk -F: '$3 >= 1000 && $3 < 65534 {print $1; exit}' /etc/passwd)
|
||||
DEFAULT_HOME=$(eval echo ~${DEFAULT_USER:-pi})
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/proxmark3" ]; then
|
||||
echo "✓ PM3 client binary present"
|
||||
exit 0
|
||||
else
|
||||
echo "✗ PM3 binary missing despite manifest match — rebuilding"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ... existing build logic ...
|
||||
```
|
||||
|
||||
And at the end of a successful build, save the manifest:
|
||||
|
||||
```bash
|
||||
# Save build manifest for future cache checks
|
||||
mkdir -p /opt/dangerous-pi
|
||||
# (manifest value is passed via a file from the pre-chroot script)
|
||||
if [ -f /tmp/pm3-build-manifest ]; then
|
||||
cp /tmp/pm3-build-manifest /opt/dangerous-pi/.pm3-build-manifest
|
||||
fi
|
||||
```
|
||||
|
||||
#### 2D. Pass files between pre-chroot and chroot
|
||||
|
||||
**File:** `pi-gen/stagePM3/01-proxmark3/00-run.sh`
|
||||
|
||||
The pre-chroot script already copies the LED patch into `${ROOTFS_DIR}/tmp/`.
|
||||
Extend it to also copy the skip flag and manifest value:
|
||||
|
||||
```bash
|
||||
# Copy skip flag if set
|
||||
if [ -f "${SCRIPT_DIR}/SKIP_PM3_BUILD" ]; then
|
||||
touch "${ROOTFS_DIR}/tmp/SKIP_PM3_BUILD"
|
||||
rm -f "${SCRIPT_DIR}/SKIP_PM3_BUILD"
|
||||
fi
|
||||
|
||||
# Always write current manifest for the chroot to save on success
|
||||
echo "$MANIFEST" > "${ROOTFS_DIR}/tmp/pm3-build-manifest"
|
||||
```
|
||||
|
||||
#### 2E. Update build-image.sh for clarity
|
||||
|
||||
Add a `--force-pm3` flag to `from-pm3` mode that removes the cached manifest,
|
||||
forcing a rebuild even if the inputs haven't changed:
|
||||
|
||||
```bash
|
||||
from-pm3)
|
||||
# ... existing logic ...
|
||||
if [ "$FORCE_PM3" = "1" ]; then
|
||||
echo "Forcing PM3 rebuild (--force-pm3 flag)"
|
||||
# Remove cached manifest from the work directory rootfs
|
||||
rm -f "${PI_GEN_DIR}/work/${IMG_NAME}/stagePM3/rootfs/opt/dangerous-pi/.pm3-build-manifest"
|
||||
fi
|
||||
```
|
||||
|
||||
And update the usage text to document the new behavior.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: File changes summary
|
||||
|
||||
### New files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `pi-gen/stageDangerousPi/00-apt-setup/00-packages` | Consolidated package list |
|
||||
| `pi-gen/stagePM3/pm3-version.conf` | Pinned PM3 repo + commit |
|
||||
|
||||
### Modified files
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `pi-gen/stagePM3/01-proxmark3/00-run.sh` | Add manifest generation, skip flag, manifest passthrough |
|
||||
| `pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh` | Remove `apt-get update`; add skip check at top; save manifest on success; pin git clone to commit |
|
||||
| `pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh` | Remove inline `apt-get install` |
|
||||
| `pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh` | Remove conditional pip check, `apt-get update`, both `apt-get install` calls |
|
||||
| `pi-gen/stageDangerousPi/04-pisugar/00-run-chroot.sh` | Remove `apt-get update` + `apt-get install` |
|
||||
| `build-image.sh` | Add `--force-pm3` flag parsing and usage docs |
|
||||
|
||||
### Not modified (left as-is)
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `pi-gen/stageDangerousPi/06-os-updates/00-packages` | Already uses file-based packages correctly |
|
||||
| `pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh` cleanup section | `apt-get clean/autoclean/autoremove` at bottom is still useful |
|
||||
| `pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh` | No apt calls to change |
|
||||
|
||||
---
|
||||
|
||||
## Risk assessment
|
||||
|
||||
| Change | Risk | Mitigation |
|
||||
|--------|------|------------|
|
||||
| Removing `apt-get update` from stagePM3 | Low — metadata inherited from stage0, same build session | Build log proves `Hit:` on all repos |
|
||||
| Consolidating packages into 00-apt-setup | Low — same packages, just installed earlier | Substage ordering (00- before 01-) guarantees execution order |
|
||||
| Removing conditional pip3 install | Low — pip3 always present from stage1/2 Python install | Verified in stage2 package lists |
|
||||
| PM3 skip logic | Medium — if manifest check has a bug, stale binary ships | Verification step checks binary exists; `--force-pm3` escape hatch |
|
||||
| Pinning PM3 commit | Low — explicit is better than implicit HEAD | Documented in pm3-version.conf; easy to bump |
|
||||
|
||||
## Testing approach
|
||||
|
||||
1. Run `./build-image.sh test` to syntax-check all modified scripts
|
||||
2. Run `./build-image.sh from-dtpi` to verify APT consolidation works (Part 1)
|
||||
3. Run `./build-image.sh from-pm3` twice — second run should skip PM3 build (Part 2)
|
||||
4. Modify `led-pwm-control.patch` slightly, rebuild — should trigger PM3 rebuild
|
||||
5. Run `./build-image.sh from-pm3 --force-pm3` — should always rebuild
|
||||
327
TEST_RESULTS_2026-01-23.md
Normal file
327
TEST_RESULTS_2026-01-23.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Dangerous Pi - Hardware Test Results
|
||||
|
||||
**Test Date**: 2026-01-23
|
||||
**Tester**: Claude (via serial console)
|
||||
**Hardware**: Raspberry Pi Zero 2 W + Proxmark3 Easy + PiSugar 2 Zero
|
||||
**Image Version**: Custom pi-gen build (January 2026)
|
||||
**Pi IP**: 192.168.0.129 (on NETGEAR13 network)
|
||||
|
||||
---
|
||||
|
||||
## Test Summary
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Serial Console | PASS | Connected via /dev/ttyUSB0 at 115200 baud |
|
||||
| Boot & Login | PASS | Logged in as dt/proxmark3 |
|
||||
| Backend Service | PASS | dangerous-pi.service running on port 8000 |
|
||||
| Frontend | PASS | Remix.js serving on port 3000, HTTP 200 |
|
||||
| PM3 Detection | PASS | Device at /dev/ttyACM0 detected |
|
||||
| PM3 Commands | PASS | hw version, hw tune working |
|
||||
| HF Card Search | PASS | MIFARE Classic 1K detected (UID: B9 78 42 3D) |
|
||||
| LF Card Search | PASS | FDX-B Animal tag detected (ID: 941-000019966456) |
|
||||
| WiFi Client Mode | PASS | Connected to NETGEAR13 (192.168.0.129) |
|
||||
| WiFi Manager API | **ISSUE** | See Issue #1 |
|
||||
| UPS Detection | **ISSUE** | See Issue #2 |
|
||||
| BLE Advertising | **ISSUE** | See Issue #3 |
|
||||
| Antenna Tuning | PASS | LF: 25.21V @ 126.32kHz, HF: 11.02V @ 13.56MHz |
|
||||
| LED Control | PASS | hw led command returns success |
|
||||
| Plugin System | PASS | Hello World plugin discovered |
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### Issue #1: WiFi Connect API Returns Failure Despite Success (HIGH)
|
||||
|
||||
**Component**: WiFi Manager API
|
||||
**Endpoint**: `POST /api/wifi/connect`
|
||||
**Severity**: High
|
||||
**Reproducible**: Yes
|
||||
|
||||
**Description**:
|
||||
When calling the WiFi connect API, the connection actually succeeds (device gets IP address), but the API returns a 503 error and falls back to AP mode.
|
||||
|
||||
**Steps to Reproduce**:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/wifi/connect \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ssid": "NETGEAR13", "password": "****"}'
|
||||
```
|
||||
|
||||
**Expected**: `{"success": true, "ip_address": "192.168.0.129"}`
|
||||
**Actual**: `{"detail":"Failed to connect to NETGEAR13"}`
|
||||
|
||||
**Log Evidence**:
|
||||
```
|
||||
[WiFi Connect] IP result: inet 192.168.0.129/24
|
||||
Device 'wlan0' successfully activated with 'f931b358-80a4-4f81-93d8-690a5cff910e'.
|
||||
Falling back to AP mode...
|
||||
[NM Management] Setting wlan0 as unmanaged...
|
||||
AP mode enabled
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
The NetworkManager successfully connects and obtains an IP, but the verification logic incorrectly determines failure and triggers AP mode fallback.
|
||||
|
||||
**Workaround**:
|
||||
After API call, manually run `sudo nmcli device set wlan0 managed yes` to complete the connection.
|
||||
|
||||
---
|
||||
|
||||
### Issue #2: PiSugar 2 UPS Not Detected at Startup (HIGH)
|
||||
|
||||
**Component**: UPS Manager
|
||||
**Severity**: High
|
||||
**Hardware**: PiSugar 2 Zero at I2C address 0x75
|
||||
**Reproducible**: Yes
|
||||
|
||||
**Description**:
|
||||
PiSugar 2 is physically connected and responds to I2C probes, but the UPS manager fails to detect it during service startup.
|
||||
|
||||
**I2C Devices Detected**:
|
||||
- 0x32 - RTC (SD3078)
|
||||
- 0x75 - PiSugar 2 battery IC (IP5209)
|
||||
|
||||
**Manual Detection Test** (Works):
|
||||
```python
|
||||
# This works when run manually after boot
|
||||
detected, driver = await PiSugarI2CDriver.detect(retries=3, retry_delay=1.0)
|
||||
# Result: PiSugar I2C: Found PiSugar 2 at 0x75 (voltage regs: 92, 23)
|
||||
# Detection result: True
|
||||
```
|
||||
|
||||
**Service Startup Log** (Fails):
|
||||
```
|
||||
✅ UPS manager started
|
||||
Failed to initialize I2C fuel gauge: [Errno 5] Input/output error
|
||||
UPS not available: Failed to initialize I2C Fuel Gauge (MAX17040/MAX17048)
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
The error message "MAX17040/MAX17048" indicates the auto-detection is falling through to the generic I2C fuel gauge driver instead of using the PiSugar I2C driver. Possible race condition with I2C bus availability at boot time.
|
||||
|
||||
**Potential Fixes**:
|
||||
1. Add longer startup delay before UPS detection (UPS_STARTUP_DELAY env var?)
|
||||
2. Add retry logic in auto_detect_driver
|
||||
3. Check if PiSugar detection is being attempted before generic I2C
|
||||
|
||||
---
|
||||
|
||||
### Issue #3: BLE Advertisement Registration Failure (MEDIUM)
|
||||
|
||||
**Component**: BLE Manager
|
||||
**Severity**: Medium
|
||||
**Reproducible**: Yes (at startup)
|
||||
|
||||
**Description**:
|
||||
BLE advertising fails to register during service startup, falling back to "basic mode".
|
||||
|
||||
**Log Evidence**:
|
||||
```
|
||||
Failed to start BLE server: Failed to register advertisement
|
||||
dbus_next.errors.DBusError: Failed to register advertisement
|
||||
Failed to start GATT server, falling back to basic mode
|
||||
✅ BLE manager started
|
||||
```
|
||||
|
||||
**API Status** (Reports OK):
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"available": true,
|
||||
"advertising": true,
|
||||
"connected_devices": 0,
|
||||
"device_name": "DangerousPi"
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
The BLE manager reports success in "basic mode" despite the GATT server advertisement failure. The distinction between full mode and basic mode is unclear. Testing BLE advertising from a mobile device would help determine actual functionality.
|
||||
|
||||
---
|
||||
|
||||
### Issue #4: Missing i2c-tools Package (LOW)
|
||||
|
||||
**Component**: System Image
|
||||
**Severity**: Low
|
||||
|
||||
**Description**:
|
||||
The `i2cdetect` command is not available, making I2C debugging more difficult.
|
||||
|
||||
```
|
||||
$ sudo i2cdetect -y 1
|
||||
sudo: i2cdetect: command not found
|
||||
```
|
||||
|
||||
**Recommendation**:
|
||||
Add `i2c-tools` to the pi-gen package list.
|
||||
|
||||
---
|
||||
|
||||
### Issue #5: PM3 Firmware/Client Version Mismatch Warning (LOW)
|
||||
|
||||
**Component**: Proxmark3 Integration
|
||||
**Severity**: Low (Cosmetic)
|
||||
|
||||
**Description**:
|
||||
The PM3 hw version command shows a warning about firmware/client mismatch.
|
||||
|
||||
**Output**:
|
||||
```
|
||||
[!] ARM firmware does not match the source at the time the client was compiled
|
||||
[!] Make sure to flash a correct and up-to-date version
|
||||
```
|
||||
|
||||
**API Status**:
|
||||
```json
|
||||
"firmware_info": {
|
||||
"bootrom_version": "Iceman/DangerousPi/master/66c7374-dirty-suspect",
|
||||
"os_version": "Iceman/DangerousPi/master/66c7374-dirty-suspect",
|
||||
"compatible": false
|
||||
}
|
||||
```
|
||||
|
||||
**Analysis**:
|
||||
The firmware (built 2026-01-04) and client (built 2026-01-13) were compiled at different times. This is expected when firmware and client are built separately, but the `compatible: false` flag may cause unnecessary warnings in the UI.
|
||||
|
||||
---
|
||||
|
||||
### Issue #6: Pydantic Protected Namespace Warning (LOW)
|
||||
|
||||
**Component**: Backend Models
|
||||
**Severity**: Low (Cosmetic)
|
||||
|
||||
**Description**:
|
||||
Pydantic logs a warning about field naming conflict.
|
||||
|
||||
**Log**:
|
||||
```
|
||||
Field "model_short" in PiModelResponse has conflict with protected namespace "model_".
|
||||
You may be able to resolve this warning by setting `model_config['protected_namespaces'] = ()`.
|
||||
```
|
||||
|
||||
**Recommendation**:
|
||||
Either rename the field or add the protected_namespaces config to the Pydantic model.
|
||||
|
||||
---
|
||||
|
||||
## Passing Tests Detail
|
||||
|
||||
### Proxmark3 Hardware
|
||||
|
||||
**hw version**:
|
||||
```
|
||||
[ Client ] Iceman/DangerousPi/master/v4.20728-299-g91263b...-suspect 2026-01-13
|
||||
[ ARM ] Iceman/DangerousPi/master/66c7374-dirty-suspect 2026-01-04
|
||||
[ FPGA ] fpga_pm3_hf.ncd, fpga_pm3_lf.ncd, fpga_pm3_felica.ncd images present
|
||||
[ Hardware ] AT91SAM7S512 Rev A, ARM7TDMI, 64K SRAM, 512K flash (71% used)
|
||||
```
|
||||
|
||||
**hw tune**:
|
||||
- LF Antenna: 25.21V @ 126.32kHz (optimal), Q factor OK
|
||||
- HF Antenna: 11.02V @ 13.56MHz, OK
|
||||
|
||||
**hf search** (Card on HF antenna):
|
||||
- Type: MIFARE Classic 1K
|
||||
- UID: B9 78 42 3D (ONUID, re-used)
|
||||
- Magic: Gen 1a
|
||||
- PRNG: weak
|
||||
|
||||
**lf search** (Card on LF antenna):
|
||||
- Type: FDX-B / ISO 11784/5 Animal tag
|
||||
- Animal ID: 941-000019966456
|
||||
- Chipset: T55xx
|
||||
|
||||
### System Info
|
||||
|
||||
**API Response** (`/api/system/info`):
|
||||
```json
|
||||
{
|
||||
"hostname": "dangerous-pi",
|
||||
"uptime": 2995.86,
|
||||
"cpu_temp": 45.08,
|
||||
"cpu": {
|
||||
"count": 2,
|
||||
"percent": 3.4,
|
||||
"temperature": 45.08
|
||||
},
|
||||
"memory_used": 115757056,
|
||||
"memory_total": 436301824,
|
||||
"disk_used": 6584954880,
|
||||
"disk_total": 7116754944
|
||||
}
|
||||
```
|
||||
|
||||
### WiFi Status (After manual fix)
|
||||
|
||||
**API Response** (`/api/wifi/status`):
|
||||
```json
|
||||
{
|
||||
"mode": "client",
|
||||
"interfaces": [{
|
||||
"name": "wlan0",
|
||||
"mac": "2c:cf:67:87:db:13",
|
||||
"is_usb": false,
|
||||
"is_up": true,
|
||||
"connected": true,
|
||||
"ssid": "NETGEAR13",
|
||||
"ip_address": "192.168.0.129",
|
||||
"mode": "managed"
|
||||
}],
|
||||
"current_ssid": "NETGEAR13",
|
||||
"current_ip": "192.168.0.129",
|
||||
"ap_ssid": "Dangerous-Pi",
|
||||
"supports_dual": false
|
||||
}
|
||||
```
|
||||
|
||||
### Plugins
|
||||
|
||||
**API Response** (`/api/plugins/list`):
|
||||
```json
|
||||
[{
|
||||
"metadata": {
|
||||
"id": "hello_world",
|
||||
"name": "Hello World Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "Example plugin demonstrating the plugin framework"
|
||||
},
|
||||
"status": "loaded",
|
||||
"enabled": false
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Priority 1 (Critical for User Experience)
|
||||
1. **Fix WiFi Connect API** - Investigate why verification fails after successful connection
|
||||
2. **Fix UPS Detection** - Add retry logic or increase startup delay for I2C initialization
|
||||
|
||||
### Priority 2 (Quality Improvements)
|
||||
3. **Investigate BLE Basic Mode** - Document what features are available in basic vs full mode
|
||||
4. **Add i2c-tools** - Include in pi-gen image for easier debugging
|
||||
|
||||
### Priority 3 (Nice to Have)
|
||||
5. **Fix Pydantic Warning** - Clean up model namespace conflict
|
||||
6. **Document PM3 Version Mismatch** - Either sync builds or suppress cosmetic warning
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
```
|
||||
Pi Model: Raspberry Pi Zero 2 W
|
||||
Kernel: 6.12.62+rpt-rpi-v8 aarch64
|
||||
Debian: Trixie
|
||||
Python: 3.13.5
|
||||
Serial: /dev/ttyUSB0 @ 115200 baud
|
||||
PM3: /dev/ttyACM0 (Proxmark3 Easy)
|
||||
I2C Bus: 1 (devices at 0x32, 0x75)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Test conducted via serial console using Python pyserial library*
|
||||
189
TEST_RESULTS_2026-03-03.md
Normal file
189
TEST_RESULTS_2026-03-03.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Dangerous Pi - Hardware Test Results
|
||||
|
||||
**Test Date**: 2026-03-03 14:40:12
|
||||
**Target**: dangerous-pi.local (http://dangerous-pi.local:8000/api)
|
||||
**Serial**: /dev/ttyUSB0 @ 115200
|
||||
**Tester**: Automated (test_hardware_2026-03-03.py)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Total Tests | 77 |
|
||||
| **PASS** | 62 |
|
||||
| **FAIL** | 0 |
|
||||
| **WARN** | 3 |
|
||||
| **SKIP** | 12 |
|
||||
| **Pass Rate** | 80.5% (excl. skips: 95.4%) |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 1: Regression - Jan 23 Issues
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 1.1 | WiFi status reports connected | ✅ PASS | ssid=XFSETUP-0D80, ip=10.0.0.249 |
|
||||
| 1.2 | UPS detected at boot | ✅ PASS | battery=100.0%, source=battery |
|
||||
| 1.3 | BLE advertising active | ✅ PASS | name=DangerousPi |
|
||||
| 1.4 | Pydantic namespace warning | ⚠️ WARN | Warning still present in logs |
|
||||
|
||||
**Phase Summary**: 3 pass, 0 fail, 1 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2: Core Health & System Endpoints
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 2.1 | Health check | ✅ PASS | 137ms |
|
||||
| 2.2 | Readiness check | ✅ PASS | 35ms |
|
||||
| 2.3 | System info | ✅ PASS | temp=46.16°C, mem=193.0MB |
|
||||
| 2.4 | System config | ✅ PASS | 16ms |
|
||||
| 2.5 | Pi model info | ✅ PASS | {'model': 'Raspberry Pi Zero 2 W Rev 1.0', 'model_short': 'Pi Zero 2 W', 'total_cores': 4, 'default_active_cores': 2, 'm |
|
||||
| 2.6 | CPU cores | ✅ PASS | total=4, online=2 |
|
||||
| 2.7 | Power restrictions | ✅ PASS | 19ms |
|
||||
| 2.8 | Header widgets | ✅ PASS | 0 widgets |
|
||||
| 2.9 | Response time < 100ms avg | ✅ PASS | avg=41ms, min=8ms, max=91ms |
|
||||
|
||||
**Phase Summary**: 9 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 3: PM3 Hardware Integration
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 3.1 | Device discovery | ✅ PASS | 1 device(s), id=pm3_119fea49 |
|
||||
| 3.2 | Device status | ✅ PASS | {'device_id': 'pm3_119fea49', 'device_path': '/dev/ttyACM0', 'friendly_name': 'ttyACM0', 'serial_number': 'iceman', 'con |
|
||||
| 3.3 | PM3 overall status | ✅ PASS | connected=None |
|
||||
| 3.4 | Create session | ✅ PASS | id=d47ea1bd-1eb5-42af-aa29-99ce066566d6 |
|
||||
| 3.5 | Execute hw version | ⚠️ WARN | Unexpected output: |
|
||||
| 3.6 | Execute hw status | ✅ PASS | 362ms |
|
||||
| 3.7 | Execute hw tune | ⚠️ WARN | No tune data in output (438ms) |
|
||||
| 3.8 | HF search | ✅ PASS | 474ms |
|
||||
| 3.9 | LF search | ✅ PASS | 426ms |
|
||||
| 3.10 | LED identify | ✅ PASS | 5700ms |
|
||||
| 3.11 | Command history | ✅ PASS | ? entries |
|
||||
| 3.12 | Session release | ✅ PASS | 13ms |
|
||||
| 3.14 | Command time < 1s | ⏭️ SKIP | hw version not tested |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 2 warn, 1 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 4: WiFi Manager
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 4.1 | WiFi status | ✅ PASS | mode=client, ssid=XFSETUP-0D80 |
|
||||
| 4.2 | Interface detection (wlan0) | ✅ PASS | mac=2c:cf:67:87:db:13, up=True |
|
||||
| 4.3 | Network scan | ✅ PASS | 16 networks found |
|
||||
| 4.4 | Saved networks | ✅ PASS | ? saved |
|
||||
| 4.5 | Current connection | ✅ PASS | ip=10.0.0.249 |
|
||||
| 4.6 | WiFi mode report | ✅ PASS | mode=client |
|
||||
|
||||
**Phase Summary**: 6 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 5: HTTPS & SSL
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 5.1 | SSL info endpoint | ✅ PASS | 833ms |
|
||||
| 5.2 | SSL enabled field present | ✅ PASS | enabled=True |
|
||||
| 5.3 | Certificate SANs | ✅ PASS | SANs=['IP Address:192.168.4.1', 'DNS:dangerous-pi.local', 'DNS:dangerous-pi', 'DNS:localhost', 'IP Address:127.0.0.1'] |
|
||||
| 5.4 | HTTPS access | ✅ PASS | 78ms |
|
||||
| 5.5 | SSL toggle endpoint | ⏭️ SKIP | Skipped to avoid disrupting connection |
|
||||
| 5.6 | Cert regeneration | ⏭️ SKIP | Skipped to avoid disrupting HTTPS state |
|
||||
| 5.7 | SSL cert script exists | ✅ PASS | Script at /opt/dangerous-pi/scripts/generate-ssl-cert.sh |
|
||||
| 5.8 | nginx config files | ✅ PASS | Found configs |
|
||||
|
||||
**Phase Summary**: 6 pass, 0 fail, 0 warn, 2 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6: Authentication & WebSocket
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 6.1 | Auth status check | ✅ PASS | auth_enabled=False |
|
||||
| 6.2 | Enable auth via serial | ⏭️ SKIP | Auth is disabled, skipping auth toggle tests (use --phase 6 to run interactively) |
|
||||
| 6.3 | Auth test 6.3 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.4 | Auth test 6.4 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.5 | Auth test 6.5 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.6 | Auth test 6.6 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.7 | Auth test 6.7 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.8 | Auth test 6.8 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.9 | Auth test 6.9 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.10 | WebSocket event stream | ⏭️ SKIP | websockets not installed |
|
||||
|
||||
**Phase Summary**: 1 pass, 0 fail, 0 warn, 9 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 7: Plugin System & Hooks
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 7.1 | Plugin discovery | ✅ PASS | Found: ['hello_world'] |
|
||||
| 7.2 | Plugin list | ✅ PASS | 1 plugin(s) |
|
||||
| 7.3 | Plugin info (hello_world) | ✅ PASS | version=1.0.0 |
|
||||
| 7.4 | Load plugin | ✅ PASS | 18ms |
|
||||
| 7.5 | Enable plugin | ✅ PASS | 11ms |
|
||||
| 7.6 | Hook trigger (pm3_command) | ✅ PASS | PM3 command executed with plugin enabled |
|
||||
| 7.7 | Plugin widgets | ✅ PASS | 14ms |
|
||||
| 7.8 | Disable plugin | ✅ PASS | 15ms |
|
||||
| 7.9 | Unload plugin | ✅ PASS | 27ms |
|
||||
| 7.10 | Invalid plugin returns 404 | ✅ PASS | 12ms |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 8: BLE & UPS Hardware
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 8.1 | BLE status API | ✅ PASS | enabled=True, advertising=True, name=DangerousPi |
|
||||
| 8.3 | Start BLE advertising | ✅ PASS | 13ms |
|
||||
| 8.2 | BLE scan from laptop | ✅ PASS | Found DangerousPi in BLE scan |
|
||||
| 8.4 | Stop BLE advertising | ✅ PASS | 588ms |
|
||||
| 8.5 | Send BLE notification | ✅ PASS | 16ms |
|
||||
| 8.7 | UPS status | ✅ PASS | battery=100.0%, voltage=4160.2755V, source=battery |
|
||||
| 8.8 | UPS thresholds | ✅ PASS | 10ms |
|
||||
| 8.9 | Power source detection | ✅ PASS | source=battery |
|
||||
| 8.10 | I2C device at 0x75 | ✅ PASS | PiSugar responds |
|
||||
|
||||
**Phase Summary**: 9 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 9: Frontend & Performance
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 9.1 | Frontend loads | ✅ PASS | 9938 bytes, has app markup |
|
||||
| 9.2 | Dashboard page | ✅ PASS | 9938 bytes, has app markup |
|
||||
| 9.3 | Commands page | ✅ PASS | 7226 bytes, has app markup |
|
||||
| 9.4 | Settings page | ✅ PASS | 7589 bytes, has app markup |
|
||||
| 9.5 | Logs page | ✅ PASS | 5584 bytes, has app markup |
|
||||
| 9.6 | Response time (10 requests) | ✅ PASS | avg=20ms, p95=28ms, min=13ms, max=28ms |
|
||||
| 9.7 | Memory usage | ✅ PASS | system total used: 192MB |
|
||||
| 9.8 | CPU temperature | ✅ PASS | 47.236°C (target < 70°C idle) |
|
||||
|
||||
**Phase Summary**: 8 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
- **1.4 Pydantic namespace warning**: Warning still present in logs
|
||||
- **3.5 Execute hw version**: Unexpected output:
|
||||
- **3.7 Execute hw tune**: No tune data in output (438ms)
|
||||
|
||||
---
|
||||
|
||||
*Generated by test_hardware_2026-03-03.py*
|
||||
208
TEST_RESULTS_2026-03-03_v2.md
Normal file
208
TEST_RESULTS_2026-03-03_v2.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Dangerous Pi - Hardware Test Results
|
||||
|
||||
**Test Date**: 2026-03-03 12:17:58
|
||||
**Target**: dangerous-pi.local (http://dangerous-pi.local:8000/api)
|
||||
**Serial**: /dev/ttyUSB0 @ 115200
|
||||
**Tester**: Automated (test_hardware_2026-03-03.py)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Total Tests | 77 |
|
||||
| **PASS** | 53 |
|
||||
| **FAIL** | 3 |
|
||||
| **WARN** | 10 |
|
||||
| **SKIP** | 11 |
|
||||
| **Pass Rate** | 68.8% (excl. skips: 80.3%) |
|
||||
|
||||
---
|
||||
|
||||
## ❌ Phase 1: Regression - Jan 23 Issues
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 1.1 | WiFi status reports connected | ✅ PASS | ssid=XFSETUP-0D80, ip=10.0.0.249 |
|
||||
| 1.2 | UPS detected at boot | ❌ FAIL | UPS not available: Failed to initialize I2C Fuel Gauge (MAX17040/MAX17048) |
|
||||
| 1.3 | BLE advertising active | ✅ PASS | name=DangerousPi |
|
||||
| 1.4 | Pydantic namespace warning | ✅ PASS | No warning found or logs unavailable |
|
||||
|
||||
**Phase Summary**: 3 pass, 1 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2: Core Health & System Endpoints
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 2.1 | Health check | ✅ PASS | 18ms |
|
||||
| 2.2 | Readiness check | ✅ PASS | 10ms |
|
||||
| 2.3 | System info | ✅ PASS | temp=46.698°C, mem=162.0MB |
|
||||
| 2.4 | System config | ✅ PASS | 10ms |
|
||||
| 2.5 | Pi model info | ✅ PASS | {'model': 'Raspberry Pi Zero 2 W Rev 1.0', 'model_short': 'Pi Zero 2 W', 'total_cores': 4, 'default_active_cores': 2, 'm |
|
||||
| 2.6 | CPU cores | ✅ PASS | total=4, online=2 |
|
||||
| 2.7 | Power restrictions | ✅ PASS | 9ms |
|
||||
| 2.8 | Header widgets | ✅ PASS | 0 widgets |
|
||||
| 2.9 | Response time < 100ms avg | ✅ PASS | avg=13ms, min=8ms, max=26ms |
|
||||
|
||||
**Phase Summary**: 9 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 3: PM3 Hardware Integration
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 3.1 | Device discovery | ✅ PASS | 1 device(s), id=pm3_119fea49 |
|
||||
| 3.2 | Device status | ✅ PASS | {'device_id': 'pm3_119fea49', 'device_path': '/dev/ttyACM0', 'friendly_name': 'ttyACM0', 'serial_number': 'iceman', 'con |
|
||||
| 3.3 | PM3 overall status | ✅ PASS | connected=None |
|
||||
| 3.4 | Create session | ✅ PASS | id=ddebed0f-ac41-4894-b2c2-823de334f4e8 |
|
||||
| 3.5 | Execute hw version | ⚠️ WARN | Unexpected output: |
|
||||
| 3.6 | Execute hw status | ✅ PASS | 382ms |
|
||||
| 3.7 | Execute hw tune | ⚠️ WARN | No tune data in output (419ms) |
|
||||
| 3.8 | HF search | ✅ PASS | 609ms |
|
||||
| 3.9 | LF search | ✅ PASS | 665ms |
|
||||
| 3.10 | LED identify | ✅ PASS | 5620ms |
|
||||
| 3.11 | Command history | ✅ PASS | ? entries |
|
||||
| 3.12 | Session release | ✅ PASS | 10ms |
|
||||
| 3.14 | Command time < 1s | ⏭️ SKIP | hw version not tested |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 2 warn, 1 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 4: WiFi Manager
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 4.1 | WiFi status | ✅ PASS | mode=client, ssid=XFSETUP-0D80 |
|
||||
| 4.2 | Interface detection (wlan0) | ✅ PASS | mac=2c:cf:67:87:db:13, up=True |
|
||||
| 4.3 | Network scan | ✅ PASS | 17 networks found |
|
||||
| 4.4 | Saved networks | ⚠️ WARN | HTTP 404 |
|
||||
| 4.5 | Current connection | ✅ PASS | ip=10.0.0.249 |
|
||||
| 4.6 | WiFi mode report | ✅ PASS | mode=client |
|
||||
|
||||
**Phase Summary**: 5 pass, 0 fail, 1 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 5: HTTPS & SSL
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 5.1 | SSL info endpoint | ✅ PASS | 19ms |
|
||||
| 5.2 | SSL enabled field present | ✅ PASS | enabled=False |
|
||||
| 5.3 | Certificate SANs | ⚠️ WARN | No certificate generated yet |
|
||||
| 5.4 | HTTPS access | ⚠️ WARN | HTTPS not responding (may not be enabled): timeout |
|
||||
| 5.5 | SSL toggle endpoint | ⏭️ SKIP | Skipped to avoid disrupting connection |
|
||||
| 5.6 | Cert regeneration | ⏭️ SKIP | Skipped to avoid disrupting HTTPS state |
|
||||
| 5.7 | SSL cert script exists | ⚠️ WARN | Script not at expected path |
|
||||
| 5.8 | nginx config files | ✅ PASS | Found configs |
|
||||
|
||||
**Phase Summary**: 3 pass, 0 fail, 3 warn, 2 skip
|
||||
|
||||
---
|
||||
|
||||
## ❌ Phase 6: Authentication & WebSocket
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 6.1 | Auth status check | ❌ FAIL | HTTP 404 |
|
||||
| 6.2 | Enable auth via serial | ⏭️ SKIP | Auth is disabled, skipping auth toggle tests (use --phase 6 to run interactively) |
|
||||
| 6.3 | Auth test 6.3 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.4 | Auth test 6.4 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.5 | Auth test 6.5 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.6 | Auth test 6.6 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.7 | Auth test 6.7 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.8 | Auth test 6.8 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.9 | Auth test 6.9 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.10 | WebSocket event stream | ❌ FAIL | Connection failed: server rejected WebSocket connection: HTTP 403 |
|
||||
|
||||
**Phase Summary**: 0 pass, 2 fail, 0 warn, 8 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 7: Plugin System & Hooks
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 7.1 | Plugin discovery | ✅ PASS | Found: ['hello_world'] |
|
||||
| 7.2 | Plugin list | ✅ PASS | 1 plugin(s) |
|
||||
| 7.3 | Plugin info (hello_world) | ✅ PASS | version=1.0.0 |
|
||||
| 7.4 | Load plugin | ✅ PASS | 72ms |
|
||||
| 7.5 | Enable plugin | ✅ PASS | 15ms |
|
||||
| 7.6 | Hook trigger (pm3_command) | ✅ PASS | PM3 command executed with plugin enabled |
|
||||
| 7.7 | Plugin widgets | ✅ PASS | 11ms |
|
||||
| 7.8 | Disable plugin | ✅ PASS | 12ms |
|
||||
| 7.9 | Unload plugin | ✅ PASS | 15ms |
|
||||
| 7.10 | Invalid plugin returns 404 | ✅ PASS | 81ms |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 8: BLE & UPS Hardware
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 8.1 | BLE status API | ✅ PASS | enabled=True, advertising=True, name=DangerousPi |
|
||||
| 8.2 | BLE scan from laptop | ⚠️ WARN | DangerousPi not found in scan (may need longer scan) |
|
||||
| 8.3 | Start BLE advertising | ✅ PASS | 5259ms |
|
||||
| 8.4 | Stop BLE advertising | ✅ PASS | 224ms |
|
||||
| 8.5 | Send BLE notification | ⚠️ WARN | HTTP 400 |
|
||||
| 8.7 | UPS status | ⚠️ WARN | UPS not available: Failed to initialize I2C Fuel Gauge (MAX17040/MAX17048) |
|
||||
| 8.8 | UPS thresholds | ✅ PASS | 11ms |
|
||||
| 8.9 | Power source detection | ⚠️ WARN | Power source unknown (UPS may not be detected) |
|
||||
| 8.10 | I2C device at 0x75 | ✅ PASS | PiSugar responds |
|
||||
|
||||
**Phase Summary**: 5 pass, 0 fail, 4 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 9: Frontend & Performance
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 9.1 | Frontend loads | ✅ PASS | 9964 bytes, has app markup |
|
||||
| 9.2 | Dashboard page | ✅ PASS | 9964 bytes, has app markup |
|
||||
| 9.3 | Commands page | ✅ PASS | 7226 bytes, has app markup |
|
||||
| 9.4 | Settings page | ✅ PASS | 7588 bytes, has app markup |
|
||||
| 9.5 | Logs page | ✅ PASS | 5584 bytes, has app markup |
|
||||
| 9.6 | Response time (10 requests) | ✅ PASS | avg=10ms, p95=15ms, min=7ms, max=15ms |
|
||||
| 9.7 | Memory usage | ✅ PASS | system total used: 171MB |
|
||||
| 9.8 | CPU temperature | ✅ PASS | 48.312°C (target < 70°C idle) |
|
||||
|
||||
**Phase Summary**: 8 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## Issues Found (FAIL)
|
||||
|
||||
### 1.2: UPS detected at boot
|
||||
- **Detail**: UPS not available: Failed to initialize I2C Fuel Gauge (MAX17040/MAX17048)
|
||||
- **Data**: `{"battery_percentage": 0.0, "voltage": 0.0, "current": 0.0, "power_source": "unknown", "battery_status": "unknown", "time_remaining": null, "temperature": null, "last_updated": null, "is_available": false, "error_message": "Failed to initialize I2C Fuel Gauge (MAX17040/MAX17048)"}`
|
||||
|
||||
### 6.1: Auth status check
|
||||
- **Detail**: HTTP 404
|
||||
|
||||
### 6.10: WebSocket event stream
|
||||
- **Detail**: Connection failed: server rejected WebSocket connection: HTTP 403
|
||||
|
||||
## Warnings
|
||||
|
||||
- **3.5 Execute hw version**: Unexpected output:
|
||||
- **3.7 Execute hw tune**: No tune data in output (419ms)
|
||||
- **4.4 Saved networks**: HTTP 404
|
||||
- **5.3 Certificate SANs**: No certificate generated yet
|
||||
- **5.4 HTTPS access**: HTTPS not responding (may not be enabled): timeout
|
||||
- **5.7 SSL cert script exists**: Script not at expected path
|
||||
- **8.2 BLE scan from laptop**: DangerousPi not found in scan (may need longer scan)
|
||||
- **8.5 Send BLE notification**: HTTP 400
|
||||
- **8.7 UPS status**: UPS not available: Failed to initialize I2C Fuel Gauge (MAX17040/MAX17048)
|
||||
- **8.9 Power source detection**: Power source unknown (UPS may not be detected)
|
||||
|
||||
---
|
||||
|
||||
*Generated by test_hardware_2026-03-03.py*
|
||||
195
TEST_RESULTS_2026-03-03_v3.md
Normal file
195
TEST_RESULTS_2026-03-03_v3.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Dangerous Pi - Hardware Test Results
|
||||
|
||||
**Test Date**: 2026-03-03 12:41:00
|
||||
**Target**: dangerous-pi.local (http://dangerous-pi.local:8000/api)
|
||||
**Serial**: /dev/ttyUSB0 @ 115200
|
||||
**Tester**: Automated (test_hardware_2026-03-03.py)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Total Tests | 77 |
|
||||
| **PASS** | 58 |
|
||||
| **FAIL** | 0 |
|
||||
| **WARN** | 9 |
|
||||
| **SKIP** | 10 |
|
||||
| **Pass Rate** | 75.3% (excl. skips: 86.6%) |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 1: Regression - Jan 23 Issues
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 1.1 | WiFi status reports connected | ✅ PASS | ssid=XFSETUP-0D80, ip=10.0.0.249 |
|
||||
| 1.2 | UPS detected at boot | ✅ PASS | battery=100.0%, source=battery |
|
||||
| 1.3 | BLE advertising active | ✅ PASS | name=DangerousPi |
|
||||
| 1.4 | Pydantic namespace warning | ⚠️ WARN | Warning still present in logs |
|
||||
|
||||
**Phase Summary**: 3 pass, 0 fail, 1 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2: Core Health & System Endpoints
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 2.1 | Health check | ✅ PASS | 17ms |
|
||||
| 2.2 | Readiness check | ✅ PASS | 15ms |
|
||||
| 2.3 | System info | ✅ PASS | temp=48.312°C, mem=170.0MB |
|
||||
| 2.4 | System config | ✅ PASS | 10ms |
|
||||
| 2.5 | Pi model info | ✅ PASS | {'model': 'Raspberry Pi Zero 2 W Rev 1.0', 'model_short': 'Pi Zero 2 W', 'total_cores': 4, 'default_active_cores': 2, 'm |
|
||||
| 2.6 | CPU cores | ✅ PASS | total=4, online=2 |
|
||||
| 2.7 | Power restrictions | ✅ PASS | 12ms |
|
||||
| 2.8 | Header widgets | ✅ PASS | 0 widgets |
|
||||
| 2.9 | Response time < 100ms avg | ✅ PASS | avg=12ms, min=8ms, max=23ms |
|
||||
|
||||
**Phase Summary**: 9 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 3: PM3 Hardware Integration
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 3.1 | Device discovery | ✅ PASS | 1 device(s), id=pm3_119fea49 |
|
||||
| 3.2 | Device status | ✅ PASS | {'device_id': 'pm3_119fea49', 'device_path': '/dev/ttyACM0', 'friendly_name': 'ttyACM0', 'serial_number': 'iceman', 'con |
|
||||
| 3.3 | PM3 overall status | ✅ PASS | connected=None |
|
||||
| 3.4 | Create session | ✅ PASS | id=0ca1fbe4-6218-46a5-9545-746797c866d2 |
|
||||
| 3.5 | Execute hw version | ✅ PASS | 1548ms |
|
||||
| 3.6 | Execute hw status | ✅ PASS | 2051ms |
|
||||
| 3.7 | Execute hw tune | ✅ PASS | 8602ms |
|
||||
| 3.8 | HF search | ✅ PASS | 9815ms |
|
||||
| 3.9 | LF search | ✅ PASS | 4950ms |
|
||||
| 3.10 | LED identify | ⚠️ WARN | HTTP timeout |
|
||||
| 3.11 | Command history | ✅ PASS | ? entries |
|
||||
| 3.12 | Session release | ✅ PASS | 10ms |
|
||||
| 3.14 | Command time < 1s | ⚠️ WARN | 1548ms (target < 1000ms) |
|
||||
|
||||
**Phase Summary**: 11 pass, 0 fail, 2 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 4: WiFi Manager
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 4.1 | WiFi status | ✅ PASS | mode=client, ssid=XFSETUP-0D80 |
|
||||
| 4.2 | Interface detection (wlan0) | ✅ PASS | mac=2c:cf:67:87:db:13, up=True |
|
||||
| 4.3 | Network scan | ✅ PASS | 13 networks found |
|
||||
| 4.4 | Saved networks | ⚠️ WARN | HTTP 404 |
|
||||
| 4.5 | Current connection | ✅ PASS | ip=10.0.0.249 |
|
||||
| 4.6 | WiFi mode report | ✅ PASS | mode=client |
|
||||
|
||||
**Phase Summary**: 5 pass, 0 fail, 1 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 5: HTTPS & SSL
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 5.1 | SSL info endpoint | ✅ PASS | 11ms |
|
||||
| 5.2 | SSL enabled field present | ✅ PASS | enabled=False |
|
||||
| 5.3 | Certificate SANs | ⚠️ WARN | No certificate generated yet |
|
||||
| 5.4 | HTTPS access | ⚠️ WARN | HTTPS not responding (may not be enabled): timeout |
|
||||
| 5.5 | SSL toggle endpoint | ⏭️ SKIP | Skipped to avoid disrupting connection |
|
||||
| 5.6 | Cert regeneration | ⏭️ SKIP | Skipped to avoid disrupting HTTPS state |
|
||||
| 5.7 | SSL cert script exists | ⚠️ WARN | Script not at expected path |
|
||||
| 5.8 | nginx config files | ✅ PASS | Found configs |
|
||||
|
||||
**Phase Summary**: 3 pass, 0 fail, 3 warn, 2 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6: Authentication & WebSocket
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 6.1 | Auth status check | ✅ PASS | auth_enabled=False |
|
||||
| 6.2 | Enable auth via serial | ⏭️ SKIP | Auth is disabled, skipping auth toggle tests (use --phase 6 to run interactively) |
|
||||
| 6.3 | Auth test 6.3 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.4 | Auth test 6.4 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.5 | Auth test 6.5 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.6 | Auth test 6.6 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.7 | Auth test 6.7 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.8 | Auth test 6.8 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.9 | Auth test 6.9 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.10 | WebSocket event stream | ✅ PASS | Received event: {'type': 'event', 'event': 'connected', 'data': {'message': 'Connected to Dangerous Pi event stream' |
|
||||
|
||||
**Phase Summary**: 2 pass, 0 fail, 0 warn, 8 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 7: Plugin System & Hooks
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 7.1 | Plugin discovery | ✅ PASS | Found: ['hello_world'] |
|
||||
| 7.2 | Plugin list | ✅ PASS | 1 plugin(s) |
|
||||
| 7.3 | Plugin info (hello_world) | ✅ PASS | version=1.0.0 |
|
||||
| 7.4 | Load plugin | ✅ PASS | 76ms |
|
||||
| 7.5 | Enable plugin | ✅ PASS | 13ms |
|
||||
| 7.6 | Hook trigger (pm3_command) | ✅ PASS | PM3 command executed with plugin enabled |
|
||||
| 7.7 | Plugin widgets | ✅ PASS | 13ms |
|
||||
| 7.8 | Disable plugin | ✅ PASS | 127ms |
|
||||
| 7.9 | Unload plugin | ✅ PASS | 17ms |
|
||||
| 7.10 | Invalid plugin returns 404 | ✅ PASS | 13ms |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 8: BLE & UPS Hardware
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 8.1 | BLE status API | ✅ PASS | enabled=True, advertising=True, name=DangerousPi |
|
||||
| 8.2 | BLE scan from laptop | ⚠️ WARN | DangerousPi not found in scan (may need longer scan) |
|
||||
| 8.3 | Start BLE advertising | ✅ PASS | 5087ms |
|
||||
| 8.4 | Stop BLE advertising | ✅ PASS | 108ms |
|
||||
| 8.5 | Send BLE notification | ⚠️ WARN | HTTP 400 |
|
||||
| 8.7 | UPS status | ✅ PASS | battery=100.0%, voltage=4208.6145V, source=battery |
|
||||
| 8.8 | UPS thresholds | ✅ PASS | 27ms |
|
||||
| 8.9 | Power source detection | ✅ PASS | source=battery |
|
||||
| 8.10 | I2C device at 0x75 | ✅ PASS | PiSugar responds |
|
||||
|
||||
**Phase Summary**: 7 pass, 0 fail, 2 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 9: Frontend & Performance
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 9.1 | Frontend loads | ✅ PASS | 9955 bytes, has app markup |
|
||||
| 9.2 | Dashboard page | ✅ PASS | 9959 bytes, has app markup |
|
||||
| 9.3 | Commands page | ✅ PASS | 7226 bytes, has app markup |
|
||||
| 9.4 | Settings page | ✅ PASS | 7588 bytes, has app markup |
|
||||
| 9.5 | Logs page | ✅ PASS | 5584 bytes, has app markup |
|
||||
| 9.6 | Response time (10 requests) | ✅ PASS | avg=11ms, p95=16ms, min=8ms, max=16ms |
|
||||
| 9.7 | Memory usage | ✅ PASS | system total used: 184MB |
|
||||
| 9.8 | CPU temperature | ✅ PASS | 50.464°C (target < 70°C idle) |
|
||||
|
||||
**Phase Summary**: 8 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
- **1.4 Pydantic namespace warning**: Warning still present in logs
|
||||
- **3.10 LED identify**: HTTP timeout
|
||||
- **3.14 Command time < 1s**: 1548ms (target < 1000ms)
|
||||
- **4.4 Saved networks**: HTTP 404
|
||||
- **5.3 Certificate SANs**: No certificate generated yet
|
||||
- **5.4 HTTPS access**: HTTPS not responding (may not be enabled): timeout
|
||||
- **5.7 SSL cert script exists**: Script not at expected path
|
||||
- **8.2 BLE scan from laptop**: DangerousPi not found in scan (may need longer scan)
|
||||
- **8.5 Send BLE notification**: HTTP 400
|
||||
|
||||
---
|
||||
|
||||
*Generated by test_hardware_2026-03-03.py*
|
||||
195
TEST_RESULTS_2026-03-03_v4.md
Normal file
195
TEST_RESULTS_2026-03-03_v4.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Dangerous Pi - Hardware Test Results
|
||||
|
||||
**Test Date**: 2026-03-03 14:05:12
|
||||
**Target**: dangerous-pi.local (http://dangerous-pi.local:8000/api)
|
||||
**Serial**: /dev/ttyUSB0 @ 115200
|
||||
**Tester**: Automated (test_hardware_2026-03-03.py)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Total Tests | 77 |
|
||||
| **PASS** | 57 |
|
||||
| **FAIL** | 0 |
|
||||
| **WARN** | 9 |
|
||||
| **SKIP** | 11 |
|
||||
| **Pass Rate** | 74.0% (excl. skips: 86.4%) |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 1: Regression - Jan 23 Issues
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 1.1 | WiFi status reports connected | ✅ PASS | ssid=XFSETUP-0D80, ip=10.0.0.249 |
|
||||
| 1.2 | UPS detected at boot | ✅ PASS | battery=100.0%, source=battery |
|
||||
| 1.3 | BLE advertising active | ✅ PASS | name=DangerousPi |
|
||||
| 1.4 | Pydantic namespace warning | ⚠️ WARN | Warning still present in logs |
|
||||
|
||||
**Phase Summary**: 3 pass, 0 fail, 1 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2: Core Health & System Endpoints
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 2.1 | Health check | ✅ PASS | 115ms |
|
||||
| 2.2 | Readiness check | ✅ PASS | 61ms |
|
||||
| 2.3 | System info | ✅ PASS | temp=47.774°C, mem=186.0MB |
|
||||
| 2.4 | System config | ✅ PASS | 181ms |
|
||||
| 2.5 | Pi model info | ✅ PASS | {'model': 'Raspberry Pi Zero 2 W Rev 1.0', 'model_short': 'Pi Zero 2 W', 'total_cores': 4, 'default_active_cores': 2, 'm |
|
||||
| 2.6 | CPU cores | ✅ PASS | total=4, online=2 |
|
||||
| 2.7 | Power restrictions | ✅ PASS | 27ms |
|
||||
| 2.8 | Header widgets | ✅ PASS | 0 widgets |
|
||||
| 2.9 | Response time < 100ms avg | ✅ PASS | avg=18ms, min=9ms, max=48ms |
|
||||
|
||||
**Phase Summary**: 9 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 3: PM3 Hardware Integration
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 3.1 | Device discovery | ✅ PASS | 1 device(s), id=pm3_119fea49 |
|
||||
| 3.2 | Device status | ✅ PASS | {'device_id': 'pm3_119fea49', 'device_path': '/dev/ttyACM0', 'friendly_name': 'ttyACM0', 'serial_number': 'iceman', 'con |
|
||||
| 3.3 | PM3 overall status | ✅ PASS | connected=None |
|
||||
| 3.4 | Create session | ✅ PASS | id=3d22b26f-9968-49d3-b9b6-8b3bb5b045ac |
|
||||
| 3.5 | Execute hw version | ⚠️ WARN | Unexpected output: |
|
||||
| 3.6 | Execute hw status | ✅ PASS | 513ms |
|
||||
| 3.7 | Execute hw tune | ⚠️ WARN | No tune data in output (740ms) |
|
||||
| 3.8 | HF search | ✅ PASS | 409ms |
|
||||
| 3.9 | LF search | ✅ PASS | 397ms |
|
||||
| 3.10 | LED identify | ✅ PASS | 5740ms |
|
||||
| 3.11 | Command history | ✅ PASS | ? entries |
|
||||
| 3.12 | Session release | ✅ PASS | 158ms |
|
||||
| 3.14 | Command time < 1s | ⏭️ SKIP | hw version not tested |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 2 warn, 1 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 4: WiFi Manager
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 4.1 | WiFi status | ✅ PASS | mode=client, ssid=XFSETUP-0D80 |
|
||||
| 4.2 | Interface detection (wlan0) | ✅ PASS | mac=2c:cf:67:87:db:13, up=True |
|
||||
| 4.3 | Network scan | ✅ PASS | 18 networks found |
|
||||
| 4.4 | Saved networks | ⚠️ WARN | HTTP 404 |
|
||||
| 4.5 | Current connection | ✅ PASS | ip=10.0.0.249 |
|
||||
| 4.6 | WiFi mode report | ✅ PASS | mode=client |
|
||||
|
||||
**Phase Summary**: 5 pass, 0 fail, 1 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 5: HTTPS & SSL
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 5.1 | SSL info endpoint | ✅ PASS | 8ms |
|
||||
| 5.2 | SSL enabled field present | ✅ PASS | enabled=False |
|
||||
| 5.3 | Certificate SANs | ⚠️ WARN | No certificate generated yet |
|
||||
| 5.4 | HTTPS access | ⚠️ WARN | HTTPS not responding (may not be enabled): timeout |
|
||||
| 5.5 | SSL toggle endpoint | ⏭️ SKIP | Skipped to avoid disrupting connection |
|
||||
| 5.6 | Cert regeneration | ⏭️ SKIP | Skipped to avoid disrupting HTTPS state |
|
||||
| 5.7 | SSL cert script exists | ⚠️ WARN | Script not at expected path |
|
||||
| 5.8 | nginx config files | ✅ PASS | Found configs |
|
||||
|
||||
**Phase Summary**: 3 pass, 0 fail, 3 warn, 2 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 6: Authentication & WebSocket
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 6.1 | Auth status check | ✅ PASS | auth_enabled=False |
|
||||
| 6.2 | Enable auth via serial | ⏭️ SKIP | Auth is disabled, skipping auth toggle tests (use --phase 6 to run interactively) |
|
||||
| 6.3 | Auth test 6.3 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.4 | Auth test 6.4 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.5 | Auth test 6.5 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.6 | Auth test 6.6 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.7 | Auth test 6.7 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.8 | Auth test 6.8 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.9 | Auth test 6.9 | ⏭️ SKIP | Auth disabled on Pi |
|
||||
| 6.10 | WebSocket event stream | ✅ PASS | Received event: {'type': 'event', 'event': 'connected', 'data': {'message': 'Connected to Dangerous Pi event stream' |
|
||||
|
||||
**Phase Summary**: 2 pass, 0 fail, 0 warn, 8 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 7: Plugin System & Hooks
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 7.1 | Plugin discovery | ✅ PASS | Found: ['hello_world'] |
|
||||
| 7.2 | Plugin list | ✅ PASS | 1 plugin(s) |
|
||||
| 7.3 | Plugin info (hello_world) | ✅ PASS | version=1.0.0 |
|
||||
| 7.4 | Load plugin | ✅ PASS | 25ms |
|
||||
| 7.5 | Enable plugin | ✅ PASS | 12ms |
|
||||
| 7.6 | Hook trigger (pm3_command) | ✅ PASS | PM3 command executed with plugin enabled |
|
||||
| 7.7 | Plugin widgets | ✅ PASS | 10ms |
|
||||
| 7.8 | Disable plugin | ✅ PASS | 19ms |
|
||||
| 7.9 | Unload plugin | ✅ PASS | 12ms |
|
||||
| 7.10 | Invalid plugin returns 404 | ✅ PASS | 10ms |
|
||||
|
||||
**Phase Summary**: 10 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Phase 8: BLE & UPS Hardware
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 8.1 | BLE status API | ✅ PASS | enabled=True, advertising=True, name=DangerousPi |
|
||||
| 8.2 | BLE scan from laptop | ⚠️ WARN | DangerousPi not found in scan (may need longer scan) |
|
||||
| 8.3 | Start BLE advertising | ✅ PASS | 4955ms |
|
||||
| 8.4 | Stop BLE advertising | ✅ PASS | 103ms |
|
||||
| 8.5 | Send BLE notification | ⚠️ WARN | HTTP 400 |
|
||||
| 8.7 | UPS status | ✅ PASS | battery=100.0%, voltage=4210.2258V, source=battery |
|
||||
| 8.8 | UPS thresholds | ✅ PASS | 44ms |
|
||||
| 8.9 | Power source detection | ✅ PASS | source=battery |
|
||||
| 8.10 | I2C device at 0x75 | ✅ PASS | PiSugar responds |
|
||||
|
||||
**Phase Summary**: 7 pass, 0 fail, 2 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 9: Frontend & Performance
|
||||
|
||||
| # | Test | Status | Detail |
|
||||
|---|------|--------|--------|
|
||||
| 9.1 | Frontend loads | ✅ PASS | 9941 bytes, has app markup |
|
||||
| 9.2 | Dashboard page | ✅ PASS | 9939 bytes, has app markup |
|
||||
| 9.3 | Commands page | ✅ PASS | 7226 bytes, has app markup |
|
||||
| 9.4 | Settings page | ✅ PASS | 7588 bytes, has app markup |
|
||||
| 9.5 | Logs page | ✅ PASS | 5584 bytes, has app markup |
|
||||
| 9.6 | Response time (10 requests) | ✅ PASS | avg=14ms, p95=19ms, min=10ms, max=19ms |
|
||||
| 9.7 | Memory usage | ✅ PASS | system total used: 189MB |
|
||||
| 9.8 | CPU temperature | ✅ PASS | 49.388°C (target < 70°C idle) |
|
||||
|
||||
**Phase Summary**: 8 pass, 0 fail, 0 warn, 0 skip
|
||||
|
||||
---
|
||||
|
||||
## Warnings
|
||||
|
||||
- **1.4 Pydantic namespace warning**: Warning still present in logs
|
||||
- **3.5 Execute hw version**: Unexpected output:
|
||||
- **3.7 Execute hw tune**: No tune data in output (740ms)
|
||||
- **4.4 Saved networks**: HTTP 404
|
||||
- **5.3 Certificate SANs**: No certificate generated yet
|
||||
- **5.4 HTTPS access**: HTTPS not responding (may not be enabled): timeout
|
||||
- **5.7 SSL cert script exists**: Script not at expected path
|
||||
- **8.2 BLE scan from laptop**: DangerousPi not found in scan (may need longer scan)
|
||||
- **8.5 Send BLE notification**: HTTP 400
|
||||
|
||||
---
|
||||
|
||||
*Generated by test_hardware_2026-03-03.py*
|
||||
@@ -3,13 +3,17 @@
|
||||
Refactored to use services for business logic.
|
||||
Session management uses PM3Service, system operations use SystemService.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from .. import config
|
||||
from ..services.container import container
|
||||
from ..managers.ups_manager import get_ups_manager
|
||||
from ..managers.ble_manager import get_ble_manager
|
||||
from ..managers.os_update_manager import get_os_update_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -676,14 +680,14 @@ async def get_ssl_info():
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and "subjectAltName" in result.stdout:
|
||||
if result.returncode == 0 and ("subjectAltName" in result.stdout or "Subject Alternative Name" in result.stdout):
|
||||
# Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
|
||||
san_line = result.stdout.strip()
|
||||
for line in san_line.split("\n"):
|
||||
if "DNS:" in line or "IP:" in line:
|
||||
if "DNS:" in line or "IP" in line:
|
||||
# Split by comma and clean up
|
||||
sans = [s.strip() for s in line.split(",")]
|
||||
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))]
|
||||
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP"))]
|
||||
break
|
||||
|
||||
# Get SHA256 fingerprint
|
||||
@@ -738,10 +742,19 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
|
||||
# Check multiple possible locations for the script
|
||||
script_candidates = [
|
||||
"/opt/dangerous-pi/scripts/generate-ssl-cert.sh",
|
||||
os.path.expanduser("~/dangerous-pi/scripts/generate-ssl-cert.sh"),
|
||||
os.path.join(os.path.dirname(__file__), "../../../scripts/generate-ssl-cert.sh"),
|
||||
]
|
||||
script_path = None
|
||||
for candidate in script_candidates:
|
||||
if os.path.exists(candidate):
|
||||
script_path = candidate
|
||||
break
|
||||
|
||||
# Check if script exists
|
||||
if not os.path.exists(script_path):
|
||||
if not script_path:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="SSL certificate generation script not found"
|
||||
@@ -975,4 +988,217 @@ async def clear_dismissed_widgets():
|
||||
plugin_manager = get_plugin_manager()
|
||||
plugin_manager.clear_dismissed()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OS Updates API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/os/info")
|
||||
async def get_os_info():
|
||||
"""Get OS-level system information.
|
||||
|
||||
Returns Debian version, kernel, architecture, uptime, hostname,
|
||||
last apt update timestamp, auto-update setting, and reboot-required status.
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
return await manager.get_os_info()
|
||||
|
||||
|
||||
@router.get("/os/updates")
|
||||
async def get_os_updates(refresh: bool = False):
|
||||
"""Get available OS package updates.
|
||||
|
||||
Returns a list of upgradable packages with current and available versions.
|
||||
Results are cached for 1 hour unless refresh=True.
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
packages = await manager.check_available_updates(force_refresh=refresh)
|
||||
return {
|
||||
"count": len(packages),
|
||||
"packages": packages,
|
||||
"upgrading": manager.is_upgrading,
|
||||
}
|
||||
|
||||
|
||||
class OsUpgradeRequest(BaseModel):
|
||||
"""Request to trigger an OS package upgrade."""
|
||||
security_only: bool = False
|
||||
|
||||
|
||||
@router.post("/os/update")
|
||||
async def run_os_update(request: OsUpgradeRequest):
|
||||
"""Trigger an OS package upgrade.
|
||||
|
||||
Runs `apt-get upgrade -y` (or unattended-upgrade --verbose for security-only).
|
||||
Only one upgrade can run at a time.
|
||||
|
||||
Args:
|
||||
security_only: If True, only install security updates
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
result = await manager.run_upgrade(security_only=request.security_only)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=409 if "already in progress" in result.get("error", "") else 500,
|
||||
detail=result.get("error", "Upgrade failed"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class AutoUpdatesRequest(BaseModel):
|
||||
"""Request to toggle automatic security updates."""
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.post("/os/auto-updates")
|
||||
async def toggle_auto_updates(request: AutoUpdatesRequest):
|
||||
"""Toggle automatic security updates.
|
||||
|
||||
Writes AUTO_SECURITY_UPDATES to the .env file and restarts the
|
||||
systemd timer that controls unattended-upgrades.
|
||||
|
||||
Args:
|
||||
enabled: True to enable, False to disable automatic security updates
|
||||
"""
|
||||
manager = get_os_update_manager()
|
||||
result = await manager.toggle_auto_updates(request.enabled)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error", "Failed to toggle auto-updates"))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ThemeDefinitionResponse(BaseModel):
|
||||
"""A single theme definition."""
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
supportsModes: List[str] = ["dark", "light", "auto"]
|
||||
defaultMode: str = "dark"
|
||||
author: str = ""
|
||||
css_url: str = ""
|
||||
source: str = "builtin" # "builtin" or "plugin"
|
||||
|
||||
|
||||
class ThemeRegistryResponse(BaseModel):
|
||||
"""Response for GET /api/system/themes."""
|
||||
themes: List[ThemeDefinitionResponse]
|
||||
|
||||
|
||||
def _scan_theme_dirs() -> List[ThemeDefinitionResponse]:
|
||||
"""Scan themes directories for installed theme packages."""
|
||||
themes_found: List[ThemeDefinitionResponse] = []
|
||||
|
||||
# Search paths: dev + production
|
||||
themes_dirs = [
|
||||
Path(__file__).parent.parent.parent / "frontend" / "themes", # dev
|
||||
Path("/opt/dangerous-pi/app/frontend/themes"), # prod
|
||||
]
|
||||
|
||||
seen_ids: set = set()
|
||||
for themes_dir in themes_dirs:
|
||||
if not themes_dir.is_dir():
|
||||
continue
|
||||
for entry in sorted(themes_dir.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
theme_json = entry / "theme.json"
|
||||
if not theme_json.exists():
|
||||
continue
|
||||
try:
|
||||
meta = json.loads(theme_json.read_text())
|
||||
theme_id = meta.get("id", entry.name)
|
||||
if theme_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(theme_id)
|
||||
themes_found.append(ThemeDefinitionResponse(
|
||||
id=theme_id,
|
||||
name=meta.get("name", theme_id),
|
||||
description=meta.get("description", ""),
|
||||
supportsModes=meta.get("supportsModes", ["dark", "light", "auto"]),
|
||||
defaultMode=meta.get("defaultMode", "dark"),
|
||||
author=meta.get("author", ""),
|
||||
css_url=f"/themes/{theme_id}/tokens.css",
|
||||
source="builtin",
|
||||
))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
print(f"Warning: bad theme.json in {entry}: {exc}")
|
||||
|
||||
return themes_found
|
||||
|
||||
|
||||
def _scan_plugin_themes() -> List[ThemeDefinitionResponse]:
|
||||
"""Collect themes registered by plugins via the theme_register hook."""
|
||||
from ..managers.plugin_manager import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
|
||||
themes: List[ThemeDefinitionResponse] = []
|
||||
if "theme_register" not in pm._hooks:
|
||||
return themes
|
||||
|
||||
import asyncio
|
||||
results = []
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
# Hooks are called synchronously here since they are simple data returns
|
||||
for callback in pm._hooks.get("theme_register", []):
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
# Schedule in running loop if available
|
||||
if loop:
|
||||
import concurrent.futures
|
||||
# Can't await in sync context; skip async hooks
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
result = callback()
|
||||
if result:
|
||||
results.append(result)
|
||||
except Exception as exc:
|
||||
print(f"Warning: theme_register hook error: {exc}")
|
||||
|
||||
for r in results:
|
||||
themes.append(ThemeDefinitionResponse(
|
||||
id=r.get("id", "unknown"),
|
||||
name=r.get("name", "Unknown Theme"),
|
||||
description=r.get("description", ""),
|
||||
supportsModes=r.get("supportsModes", ["dark", "light", "auto"]),
|
||||
defaultMode=r.get("defaultMode", "dark"),
|
||||
author=r.get("author", ""),
|
||||
css_url=r.get("css_url", f"/themes/{r.get('id', 'unknown')}/tokens.css"),
|
||||
source="plugin",
|
||||
))
|
||||
|
||||
return themes
|
||||
|
||||
|
||||
@router.get("/themes", response_model=ThemeRegistryResponse)
|
||||
async def get_available_themes():
|
||||
"""Get available themes from disk and plugin registry.
|
||||
|
||||
Scans the themes/ directory for installed theme packages
|
||||
and collects themes registered by plugins via the theme_register hook.
|
||||
"""
|
||||
builtin = _scan_theme_dirs()
|
||||
plugin_themes = _scan_plugin_themes()
|
||||
|
||||
# Merge, preferring builtin for duplicate IDs
|
||||
seen = {t.id for t in builtin}
|
||||
all_themes = list(builtin)
|
||||
for pt in plugin_themes:
|
||||
if pt.id not in seen:
|
||||
all_themes.append(pt)
|
||||
seen.add(pt.id)
|
||||
|
||||
return ThemeRegistryResponse(themes=all_themes)
|
||||
|
||||
return {"success": True, "message": "Dismissed widgets cleared"}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
Refactored to use UpdateService for all business logic.
|
||||
Endpoints are now thin adapters that convert HTTP requests/responses.
|
||||
|
||||
Supports both legacy whole-system operations and per-component operations.
|
||||
"""
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, HTTPException, Path
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.container import container
|
||||
@@ -14,6 +16,31 @@ from ..managers.ble_manager import get_ble_manager, NotificationType
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response / request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ComponentUpdateInfo(BaseModel):
|
||||
"""Per-component update information."""
|
||||
component_id: str
|
||||
current_version: Optional[str] = None
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_size: Optional[int] = None
|
||||
compatible: bool = True
|
||||
incompatible_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PluginUpdateInfo(BaseModel):
|
||||
"""Per-plugin update information."""
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_size: Optional[int] = None
|
||||
source_repo: str = ""
|
||||
|
||||
|
||||
class UpdateCheckResponse(BaseModel):
|
||||
"""Response model for update check."""
|
||||
update_available: bool
|
||||
@@ -24,6 +51,8 @@ class UpdateCheckResponse(BaseModel):
|
||||
is_prerelease: bool = False
|
||||
download_size: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
components: List[ComponentUpdateInfo] = []
|
||||
plugins: List[PluginUpdateInfo] = []
|
||||
|
||||
|
||||
class UpdateProgressResponse(BaseModel):
|
||||
@@ -34,6 +63,8 @@ class UpdateProgressResponse(BaseModel):
|
||||
download_progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
last_check: Optional[str] = None
|
||||
active_component: Optional[str] = None
|
||||
components: List[dict] = []
|
||||
|
||||
|
||||
class ReleaseNotesRequest(BaseModel):
|
||||
@@ -41,15 +72,17 @@ class ReleaseNotesRequest(BaseModel):
|
||||
version: Optional[str] = None
|
||||
|
||||
|
||||
class ComponentDownloadRequest(BaseModel):
|
||||
"""Optional request body for selective download."""
|
||||
components: Optional[List[str]] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _service_error_to_http_status(error_code: str) -> int:
|
||||
"""Map service error codes to HTTP status codes.
|
||||
|
||||
Args:
|
||||
error_code: Service error code
|
||||
|
||||
Returns:
|
||||
HTTP status code
|
||||
"""
|
||||
"""Map service error codes to HTTP status codes."""
|
||||
codes = {
|
||||
"update_check_error": 500,
|
||||
"no_update_available": 400,
|
||||
@@ -62,35 +95,44 @@ def _service_error_to_http_status(error_code: str) -> int:
|
||||
"release_notes_error": 500,
|
||||
"check_download_error": 500,
|
||||
"full_update_error": 500,
|
||||
"manifest_error": 500,
|
||||
"component_not_available": 400,
|
||||
"component_download_error": 500,
|
||||
"component_install_error": 500,
|
||||
"rollback_error": 500,
|
||||
}
|
||||
return codes.get(error_code, 500)
|
||||
|
||||
|
||||
@router.get("/check", response_model=UpdateCheckResponse)
|
||||
async def check_for_updates():
|
||||
"""Check for available updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
result = await container.update_service.check_for_updates()
|
||||
|
||||
def _raise_on_error(result):
|
||||
"""Raise HTTPException if result indicates failure."""
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
detail=result.error.message,
|
||||
)
|
||||
|
||||
# Send BLE notification if update is available
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy endpoints (backward-compatible)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/check", response_model=UpdateCheckResponse)
|
||||
async def check_for_updates():
|
||||
"""Check for available updates (components + plugins)."""
|
||||
result = await container.update_service.check_for_updates()
|
||||
_raise_on_error(result)
|
||||
|
||||
# BLE notification
|
||||
if result.data.get("update_available"):
|
||||
try:
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.UPDATE_AVAILABLE,
|
||||
f"Update available: v{result.data['latest_version']}",
|
||||
{"version": result.data["latest_version"]}
|
||||
{"version": result.data["latest_version"]},
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return UpdateCheckResponse(**result.data)
|
||||
@@ -98,108 +140,126 @@ async def check_for_updates():
|
||||
|
||||
@router.get("/progress", response_model=UpdateProgressResponse)
|
||||
async def get_update_progress():
|
||||
"""Get current update progress.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
"""Get current update progress."""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return UpdateProgressResponse(**result.data)
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def download_update():
|
||||
"""Download the available update.
|
||||
async def download_update(body: Optional[ComponentDownloadRequest] = None):
|
||||
"""Download available updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
Without a body, downloads all available components (legacy behavior).
|
||||
With ``{"components": ["frontend"]}``, downloads only the specified ones.
|
||||
"""
|
||||
if body and body.components:
|
||||
results = {}
|
||||
for comp_id in body.components:
|
||||
r = await container.update_service.download_component(comp_id)
|
||||
_raise_on_error(r)
|
||||
results[comp_id] = r.data
|
||||
return {"message": "Components downloaded", "components": results}
|
||||
|
||||
result = await container.update_service.download_update()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {"message": result.data["message"]}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install_update():
|
||||
"""Install the downloaded update.
|
||||
async def install_update(body: Optional[ComponentDownloadRequest] = None):
|
||||
"""Install downloaded updates.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
Without a body, installs all downloaded components (legacy behavior).
|
||||
With ``{"components": ["frontend"]}``, installs only the specified ones.
|
||||
"""
|
||||
if body and body.components:
|
||||
results = {}
|
||||
for comp_id in body.components:
|
||||
r = await container.update_service.install_component(comp_id)
|
||||
_raise_on_error(r)
|
||||
results[comp_id] = r.data
|
||||
return {"message": "Components installed", "components": results}
|
||||
|
||||
result = await container.update_service.install_update()
|
||||
_raise_on_error(result)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
# Send BLE notification
|
||||
# BLE notification
|
||||
try:
|
||||
ble_manager = get_ble_manager()
|
||||
await ble_manager.send_notification(
|
||||
NotificationType.UPDATE_COMPLETE,
|
||||
"Update installed successfully",
|
||||
{"restart_required": True}
|
||||
{"restart_required": True},
|
||||
)
|
||||
except Exception:
|
||||
# BLE notification failure shouldn't affect the response
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": result.data["message"],
|
||||
"restart_required": result.data.get("requires_restart", True)
|
||||
"restart_required": result.data.get("requires_restart", True),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/release-notes", response_model=dict)
|
||||
async def get_release_notes(request: ReleaseNotesRequest):
|
||||
"""Get release notes for a specific version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
|
||||
Args:
|
||||
request: Version to get notes for (latest if not specified)
|
||||
"""
|
||||
"""Get release notes for a specific version."""
|
||||
result = await container.update_service.get_release_notes(request.version)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {
|
||||
"version": result.data["version"],
|
||||
"notes": result.data["release_notes"]
|
||||
"notes": result.data["release_notes"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/current-version")
|
||||
async def get_current_version():
|
||||
"""Get current system version.
|
||||
|
||||
Uses UpdateService for business logic.
|
||||
"""
|
||||
"""Get current system version."""
|
||||
result = await container.update_service.get_progress()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=_service_error_to_http_status(result.error.code),
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
_raise_on_error(result)
|
||||
return {
|
||||
"version": result.data["current_version"],
|
||||
"last_check": result.data["last_check"]
|
||||
"last_check": result.data["last_check"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component-level endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/components")
|
||||
async def get_installed_components():
|
||||
"""Get installed component manifest."""
|
||||
result = await container.update_service.get_installed_components()
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/download")
|
||||
async def download_component(
|
||||
component_id: str = Path(description="Component ID (pm3, frontend, backend, theme)"),
|
||||
):
|
||||
"""Download a single component update."""
|
||||
result = await container.update_service.download_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/install")
|
||||
async def install_component(
|
||||
component_id: str = Path(description="Component ID"),
|
||||
):
|
||||
"""Install a single downloaded component."""
|
||||
result = await container.update_service.install_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
|
||||
@router.post("/components/{component_id}/rollback")
|
||||
async def rollback_component(
|
||||
component_id: str = Path(description="Component ID"),
|
||||
):
|
||||
"""Rollback a component to its backup."""
|
||||
result = await container.update_service.rollback_component(component_id)
|
||||
_raise_on_error(result)
|
||||
return result.data
|
||||
|
||||
@@ -30,6 +30,9 @@ VERSION = os.getenv("VERSION", "0.1.0")
|
||||
# Update settings
|
||||
GITHUB_REPO = os.getenv("GITHUB_REPO", "dangerous-tacos/dangerous-pi")
|
||||
UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour
|
||||
COMPONENT_MANIFEST_PATH = os.getenv(
|
||||
"COMPONENT_MANIFEST_PATH", "/opt/dangerous-pi/component-manifest.json"
|
||||
)
|
||||
|
||||
# Wi-Fi settings
|
||||
WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0")
|
||||
|
||||
@@ -204,6 +204,17 @@ app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], depe
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(ws_router, prefix="/ws", tags=["websocket"])
|
||||
|
||||
# Serve theme token CSS from themes/ directory
|
||||
theme_paths = [
|
||||
Path(__file__).parent.parent / "frontend" / "themes", # Development
|
||||
Path("/opt/dangerous-pi/app/frontend/themes"), # Production
|
||||
]
|
||||
for tp in theme_paths:
|
||||
if tp.exists() and tp.is_dir():
|
||||
app.mount("/themes", StaticFiles(directory=str(tp)), name="themes")
|
||||
print(f"🎨 Serving themes from: {tp}")
|
||||
break
|
||||
|
||||
# Serve frontend static files if build directory exists
|
||||
# In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build
|
||||
# Priority: 1) check relative to working directory, 2) check absolute production path
|
||||
|
||||
@@ -210,7 +210,7 @@ class BLEManager:
|
||||
}
|
||||
|
||||
async def _check_bluetooth_adapter(self) -> bool:
|
||||
"""Check if Bluetooth adapter is available.
|
||||
"""Check if Bluetooth adapter is available, unblocking and powering on if needed.
|
||||
|
||||
Returns:
|
||||
True if adapter is available, False otherwise
|
||||
@@ -224,8 +224,20 @@ class BLEManager:
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
# If we get output with "Controller", we have an adapter
|
||||
return b"Controller" in stdout
|
||||
if b"Controller" not in stdout:
|
||||
return False
|
||||
|
||||
# Unblock Bluetooth if soft-blocked by rfkill
|
||||
await self._run_cmd("rfkill", "unblock", "bluetooth")
|
||||
|
||||
# Power on the adapter via bluetoothctl
|
||||
out = await self._run_cmd("bluetoothctl", "power", "on")
|
||||
if b"succeeded" in out.lower() or b"yes" in out.lower():
|
||||
logger.info("Bluetooth adapter powered on")
|
||||
else:
|
||||
logger.warning("Bluetooth power on response: %s", out.decode(errors='replace').strip())
|
||||
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("bluetoothctl not found - BlueZ not installed")
|
||||
@@ -234,6 +246,19 @@ class BLEManager:
|
||||
logger.error("Error checking Bluetooth adapter: %s", e)
|
||||
return False
|
||||
|
||||
async def _run_cmd(self, *args: str) -> bytes:
|
||||
"""Run a command and return stdout."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
return stdout
|
||||
except FileNotFoundError:
|
||||
return b""
|
||||
|
||||
async def _set_device_name(self, name: str):
|
||||
"""Set the Bluetooth device name.
|
||||
|
||||
|
||||
312
app/backend/managers/os_update_manager.py
Normal file
312
app/backend/managers/os_update_manager.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""OS Update Manager for Dangerous Pi.
|
||||
|
||||
Provides visibility into OS-level package updates and allows triggering
|
||||
apt upgrades from the web UI. Works alongside unattended-upgrades which
|
||||
handles automatic security patches.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@dataclass
|
||||
class OsPackageUpdate:
|
||||
"""A single upgradable OS package."""
|
||||
name: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
architecture: str = ""
|
||||
origin: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class OsInfo:
|
||||
"""OS-level system information."""
|
||||
debian_version: str = ""
|
||||
debian_codename: str = ""
|
||||
kernel: str = ""
|
||||
architecture: str = ""
|
||||
uptime_seconds: float = 0
|
||||
hostname: str = ""
|
||||
last_apt_update: Optional[str] = None
|
||||
auto_security_updates: bool = True
|
||||
reboot_required: bool = False
|
||||
|
||||
|
||||
class OsUpdateManager:
|
||||
"""Manages OS-level package updates."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: List[OsPackageUpdate] = []
|
||||
self._cache_time: float = 0
|
||||
self._cache_ttl: float = 3600 # 1 hour
|
||||
self._upgrading: bool = False
|
||||
self._upgrade_output: List[str] = []
|
||||
self._env_path = Path(os.getenv("ENV_FILE", "/opt/dangerous-pi/.env"))
|
||||
|
||||
async def get_os_info(self) -> Dict[str, Any]:
|
||||
"""Get OS-level system information."""
|
||||
info = OsInfo()
|
||||
|
||||
# Debian version
|
||||
try:
|
||||
os_release = Path("/etc/os-release")
|
||||
if os_release.exists():
|
||||
content = os_release.read_text()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("VERSION_ID="):
|
||||
info.debian_version = line.split("=", 1)[1].strip('"')
|
||||
elif line.startswith("VERSION_CODENAME="):
|
||||
info.debian_codename = line.split("=", 1)[1].strip('"')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Kernel
|
||||
info.kernel = platform.release()
|
||||
|
||||
# Architecture
|
||||
info.architecture = platform.machine()
|
||||
|
||||
# Uptime
|
||||
try:
|
||||
uptime_path = Path("/proc/uptime")
|
||||
if uptime_path.exists():
|
||||
info.uptime_seconds = float(uptime_path.read_text().split()[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hostname
|
||||
info.hostname = platform.node()
|
||||
|
||||
# Last apt update (mtime of apt lists directory)
|
||||
try:
|
||||
apt_lists = Path("/var/lib/apt/lists")
|
||||
if apt_lists.exists():
|
||||
mtime = apt_lists.stat().st_mtime
|
||||
from datetime import datetime, timezone
|
||||
info.last_apt_update = datetime.fromtimestamp(
|
||||
mtime, tz=timezone.utc
|
||||
).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto security updates setting
|
||||
info.auto_security_updates = self._read_auto_updates_setting()
|
||||
|
||||
# Reboot required
|
||||
info.reboot_required = Path("/var/run/reboot-required").exists()
|
||||
|
||||
return {
|
||||
"debian_version": info.debian_version,
|
||||
"debian_codename": info.debian_codename,
|
||||
"kernel": info.kernel,
|
||||
"architecture": info.architecture,
|
||||
"uptime_seconds": info.uptime_seconds,
|
||||
"hostname": info.hostname,
|
||||
"last_apt_update": info.last_apt_update,
|
||||
"auto_security_updates": info.auto_security_updates,
|
||||
"reboot_required": info.reboot_required,
|
||||
}
|
||||
|
||||
async def check_available_updates(self, force_refresh: bool = False) -> List[Dict[str, str]]:
|
||||
"""Check for available OS package updates.
|
||||
|
||||
Returns cached results unless force_refresh=True or cache has expired.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force_refresh and self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return [self._package_to_dict(p) for p in self._cache]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"apt", "list", "--upgradable", "-qq",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||||
output = stdout.decode(errors="replace").strip()
|
||||
|
||||
packages = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("Listing"):
|
||||
continue
|
||||
pkg = self._parse_apt_line(line)
|
||||
if pkg:
|
||||
packages.append(pkg)
|
||||
|
||||
self._cache = packages
|
||||
self._cache_time = now
|
||||
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
# Return stale cache on error rather than failing
|
||||
if not self._cache:
|
||||
return [{"name": "error", "current_version": "", "available_version": str(e), "architecture": "", "origin": ""}]
|
||||
|
||||
return [self._package_to_dict(p) for p in self._cache]
|
||||
|
||||
async def run_upgrade(self, security_only: bool = False) -> Dict[str, Any]:
|
||||
"""Trigger an apt upgrade.
|
||||
|
||||
Returns the result after completion. Only one upgrade can run at a time.
|
||||
Uses create_subprocess_exec (not shell) to avoid injection risks.
|
||||
"""
|
||||
if self._upgrading:
|
||||
return {"success": False, "error": "An upgrade is already in progress"}
|
||||
|
||||
self._upgrading = True
|
||||
self._upgrade_output = []
|
||||
|
||||
try:
|
||||
if security_only:
|
||||
cmd = ["sudo", "unattended-upgrade", "--verbose"]
|
||||
else:
|
||||
cmd = ["sudo", "apt-get", "upgrade", "-y"]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
|
||||
)
|
||||
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode(errors="replace").rstrip()
|
||||
self._upgrade_output.append(decoded)
|
||||
|
||||
await proc.wait()
|
||||
|
||||
# Invalidate cache after upgrade
|
||||
self._cache = []
|
||||
self._cache_time = 0
|
||||
|
||||
success = proc.returncode == 0
|
||||
return {
|
||||
"success": success,
|
||||
"return_code": proc.returncode,
|
||||
"output": self._upgrade_output,
|
||||
"reboot_required": Path("/var/run/reboot-required").exists(),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "output": self._upgrade_output}
|
||||
|
||||
finally:
|
||||
self._upgrading = False
|
||||
|
||||
async def toggle_auto_updates(self, enabled: bool) -> Dict[str, Any]:
|
||||
"""Toggle automatic security updates by writing to .env and restarting the timer."""
|
||||
try:
|
||||
self._write_auto_updates_setting(enabled)
|
||||
|
||||
# Restart the toggle service to apply
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sudo", "systemctl", "restart", "dangerous-pi-auto-updates.service",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"auto_security_updates": enabled,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
@property
|
||||
def is_upgrading(self) -> bool:
|
||||
return self._upgrading
|
||||
|
||||
@property
|
||||
def upgrade_output(self) -> List[str]:
|
||||
return list(self._upgrade_output)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def _parse_apt_line(self, line: str) -> Optional[OsPackageUpdate]:
|
||||
"""Parse a line from `apt list --upgradable -qq`.
|
||||
|
||||
Format: package/origin version arch [upgradable from: old_version]
|
||||
"""
|
||||
match = re.match(
|
||||
r"^(\S+?)(?:/(\S+))?\s+(\S+)\s+(\S+)\s+\[upgradable from:\s+(\S+)\]",
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
return OsPackageUpdate(
|
||||
name=match.group(1),
|
||||
origin=match.group(2) or "",
|
||||
available_version=match.group(3),
|
||||
architecture=match.group(4),
|
||||
current_version=match.group(5),
|
||||
)
|
||||
return None
|
||||
|
||||
def _package_to_dict(self, pkg: OsPackageUpdate) -> Dict[str, str]:
|
||||
return {
|
||||
"name": pkg.name,
|
||||
"current_version": pkg.current_version,
|
||||
"available_version": pkg.available_version,
|
||||
"architecture": pkg.architecture,
|
||||
"origin": pkg.origin,
|
||||
}
|
||||
|
||||
def _read_auto_updates_setting(self) -> bool:
|
||||
"""Read AUTO_SECURITY_UPDATES from .env file."""
|
||||
try:
|
||||
if self._env_path.exists():
|
||||
for line in self._env_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("AUTO_SECURITY_UPDATES="):
|
||||
val = line.split("=", 1)[1].strip().lower()
|
||||
return val != "false"
|
||||
except Exception:
|
||||
pass
|
||||
return True # Default: enabled
|
||||
|
||||
def _write_auto_updates_setting(self, enabled: bool):
|
||||
"""Write AUTO_SECURITY_UPDATES to .env file."""
|
||||
value = "true" if enabled else "false"
|
||||
env_line = f"AUTO_SECURITY_UPDATES={value}"
|
||||
|
||||
if not self._env_path.exists():
|
||||
self._env_path.write_text(env_line + "\n")
|
||||
return
|
||||
|
||||
lines = self._env_path.read_text().splitlines()
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("AUTO_SECURITY_UPDATES="):
|
||||
lines[i] = env_line
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
lines.append(env_line)
|
||||
|
||||
self._env_path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
# Singleton
|
||||
_os_update_manager: Optional[OsUpdateManager] = None
|
||||
|
||||
|
||||
def get_os_update_manager() -> OsUpdateManager:
|
||||
"""Get or create the OS update manager singleton."""
|
||||
global _os_update_manager
|
||||
if _os_update_manager is None:
|
||||
_os_update_manager = OsUpdateManager()
|
||||
return _os_update_manager
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,6 +17,8 @@ from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List, Callable, Set
|
||||
import sys
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .. import config
|
||||
|
||||
|
||||
@@ -400,6 +403,18 @@ class PluginBase:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginUpdateInfo:
|
||||
"""Information about an available plugin update."""
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str = ""
|
||||
download_url: str = ""
|
||||
download_size: Optional[int] = None
|
||||
source_repo: str = ""
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
|
||||
|
||||
@@ -790,6 +805,121 @@ class PluginManager:
|
||||
del self._header_widgets[widget_id]
|
||||
print(f"Expired widget removed: {widget_id}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Plugin Update Checking
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_github_repo(homepage: Optional[str]) -> Optional[str]:
|
||||
"""Extract 'owner/repo' from a GitHub URL.
|
||||
|
||||
Args:
|
||||
homepage: Plugin homepage URL
|
||||
|
||||
Returns:
|
||||
'owner/repo' string or None
|
||||
"""
|
||||
if not homepage:
|
||||
return None
|
||||
m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", homepage)
|
||||
return m.group(1) if m else None
|
||||
|
||||
async def check_plugin_updates(self) -> List[PluginUpdateInfo]:
|
||||
"""Check all installed plugins for available updates.
|
||||
|
||||
Queries each plugin's GitHub repo (derived from homepage) for
|
||||
newer releases. Skips plugins without a GitHub homepage.
|
||||
|
||||
Returns:
|
||||
List of PluginUpdateInfo for plugins with updates available
|
||||
"""
|
||||
updates: List[PluginUpdateInfo] = []
|
||||
|
||||
plugins_to_check = []
|
||||
for plugin_id, info in self._plugins.items():
|
||||
repo = self._extract_github_repo(info.metadata.homepage)
|
||||
if repo:
|
||||
plugins_to_check.append((plugin_id, info, repo))
|
||||
|
||||
if not plugins_to_check:
|
||||
return updates
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for plugin_id, info, repo in plugins_to_check:
|
||||
try:
|
||||
update = await self._check_single_plugin_update(
|
||||
session, plugin_id, info, repo
|
||||
)
|
||||
if update:
|
||||
updates.append(update)
|
||||
except Exception as e:
|
||||
print(f"Error checking updates for plugin {plugin_id}: {e}")
|
||||
|
||||
return updates
|
||||
|
||||
async def _check_single_plugin_update(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
plugin_id: str,
|
||||
info: PluginInfo,
|
||||
repo: str,
|
||||
) -> Optional[PluginUpdateInfo]:
|
||||
"""Check a single plugin for available update.
|
||||
|
||||
Args:
|
||||
session: aiohttp session
|
||||
plugin_id: Plugin identifier
|
||||
info: Current plugin info
|
||||
repo: GitHub 'owner/repo' string
|
||||
|
||||
Returns:
|
||||
PluginUpdateInfo if update available, None otherwise
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/releases/latest"
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
data = await resp.json()
|
||||
|
||||
latest_tag = data.get("tag_name", "").lstrip("v")
|
||||
current_ver = info.metadata.version
|
||||
|
||||
if not self._is_newer_plugin_version(latest_tag, current_ver):
|
||||
return None
|
||||
|
||||
# Find a suitable download asset
|
||||
download_url = ""
|
||||
download_size = None
|
||||
for asset in data.get("assets", []):
|
||||
if asset["name"].endswith((".tar.gz", ".zip")):
|
||||
download_url = asset["browser_download_url"]
|
||||
download_size = asset.get("size")
|
||||
break
|
||||
|
||||
# Fall back to source tarball
|
||||
if not download_url:
|
||||
download_url = data.get("tarball_url", "")
|
||||
|
||||
return PluginUpdateInfo(
|
||||
plugin_id=plugin_id,
|
||||
current_version=current_ver,
|
||||
available_version=latest_tag,
|
||||
changelog=data.get("body", ""),
|
||||
download_url=download_url,
|
||||
download_size=download_size,
|
||||
source_repo=repo,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_newer_plugin_version(latest: str, current: str) -> bool:
|
||||
"""Compare two version strings, returning True if latest > current."""
|
||||
def parse(v: str) -> tuple:
|
||||
return tuple(int(x) for x in re.split(r'[-+]', v)[0].split('.'))
|
||||
try:
|
||||
return parse(latest) > parse(current)
|
||||
except (ValueError, AttributeError):
|
||||
return latest != current
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
_plugin_manager: Optional[PluginManager] = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,12 @@
|
||||
This service provides software update operations that can be consumed by
|
||||
multiple interfaces (REST API, BLE GATT, etc.).
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import asdict
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus
|
||||
from ..managers.update_manager import (
|
||||
UpdateManager, UpdateProgress, UpdateStatus, ComponentId,
|
||||
)
|
||||
from .pm3_service import PM3ServiceError, PM3ServiceResult
|
||||
|
||||
|
||||
@@ -156,6 +159,15 @@ class UpdateService:
|
||||
try:
|
||||
progress = await self.update_manager.get_progress()
|
||||
|
||||
comp_list = []
|
||||
for comp_id, cp in progress.component_progress.items():
|
||||
comp_list.append({
|
||||
"component_id": cp.component_id,
|
||||
"status": cp.status.value,
|
||||
"download_progress": cp.download_progress,
|
||||
"error_message": cp.error_message,
|
||||
})
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
@@ -164,7 +176,9 @@ class UpdateService:
|
||||
"available_version": progress.available_version,
|
||||
"download_progress": progress.download_progress,
|
||||
"error_message": progress.error_message,
|
||||
"last_check": progress.last_check
|
||||
"last_check": progress.last_check,
|
||||
"active_component": progress.active_component,
|
||||
"components": comp_list,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -310,3 +324,151 @@ class UpdateService:
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Component-level operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_installed_components(self) -> PM3ServiceResult:
|
||||
"""Get the installed component manifest.
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult with component manifest data
|
||||
"""
|
||||
try:
|
||||
manifest = self.update_manager.get_manifest()
|
||||
components = {}
|
||||
for comp_id, comp in manifest.components.items():
|
||||
components[comp_id] = asdict(comp)
|
||||
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"schema_version": manifest.schema_version,
|
||||
"system_version": manifest.system_version,
|
||||
"components": components,
|
||||
"plugins": manifest.plugins,
|
||||
"last_updated": manifest.last_updated,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="manifest_error",
|
||||
message="Failed to get component manifest",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def download_component(self, component_id: str) -> PM3ServiceResult:
|
||||
"""Download a specific component update.
|
||||
|
||||
Args:
|
||||
component_id: Component to download (pm3, frontend, backend, theme)
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating download success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.download_component(component_id)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Component {component_id} downloaded successfully",
|
||||
"component_id": component_id,
|
||||
"ready_to_install": True,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_not_available",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_download_error",
|
||||
message=f"Error downloading {component_id}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def install_component(self, component_id: str) -> PM3ServiceResult:
|
||||
"""Install a downloaded component update.
|
||||
|
||||
Args:
|
||||
component_id: Component to install
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating install success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.install_component(component_id)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Component {component_id} installed successfully",
|
||||
"component_id": component_id,
|
||||
"requires_restart": component_id in (
|
||||
ComponentId.BACKEND, ComponentId.PM3
|
||||
),
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_install_error",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="component_install_error",
|
||||
message=f"Error installing {component_id}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
async def rollback_component(self, component_id: str) -> PM3ServiceResult:
|
||||
"""Rollback a component to its previous version.
|
||||
|
||||
Args:
|
||||
component_id: Component to rollback
|
||||
|
||||
Returns:
|
||||
PM3ServiceResult indicating rollback success/failure
|
||||
"""
|
||||
try:
|
||||
success = await self.update_manager.rollback_component(component_id)
|
||||
return PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"message": f"Component {component_id} rolled back successfully",
|
||||
"component_id": component_id,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="rollback_error",
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="rollback_error",
|
||||
message=f"Error rolling back {component_id}",
|
||||
details=str(e)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ConnectDialog({ network, onClose, isSubmitting }: Connec
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.9)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.9)",
|
||||
backdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function DeviceSelector({
|
||||
style={{
|
||||
marginBottom: "var(--space-3)",
|
||||
padding: "var(--space-3)",
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
background: "rgba(var(--color-error-rgb), 0.1)",
|
||||
borderLeft: "3px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
@@ -318,7 +318,7 @@ export default function DeviceSelector({
|
||||
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
|
||||
opacity: isSelectable || isFlashing ? 1 : 0.6,
|
||||
transition: "all 150ms ease",
|
||||
background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent",
|
||||
background: isSelected ? "rgba(var(--color-primary-rgb), 0.05)" : "transparent",
|
||||
position: "relative",
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -336,7 +336,7 @@ export default function DeviceSelector({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.8)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.8)",
|
||||
borderRadius: "var(--radius)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -442,7 +442,7 @@ export default function DeviceSelector({
|
||||
style={{
|
||||
marginTop: "var(--space-2)",
|
||||
padding: "var(--space-2)",
|
||||
background: "rgba(217, 119, 6, 0.1)",
|
||||
background: "rgba(var(--color-warning-rgb), 0.1)",
|
||||
borderLeft: "3px solid var(--color-warning)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: "0.75rem",
|
||||
@@ -538,7 +538,7 @@ export default function DeviceSelector({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.75)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
@@ -600,7 +600,7 @@ export default function DeviceSelector({
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(239, 68, 68, 0.1)",
|
||||
background: "rgba(var(--color-error-rgb), 0.1)",
|
||||
borderLeft: "3px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "var(--space-3)",
|
||||
|
||||
@@ -30,7 +30,7 @@ export function LoginPrompt() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.75)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.75)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.95)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.95)",
|
||||
backdropFilter: "blur(8px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -203,7 +203,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--space-3)",
|
||||
background: "rgba(255, 107, 107, 0.1)",
|
||||
background: "rgba(var(--color-error-rgb), 0.1)",
|
||||
border: "1px solid var(--color-error)",
|
||||
borderRadius: "var(--radius)",
|
||||
marginBottom: "var(--space-4)",
|
||||
|
||||
155
app/frontend/app/lib/ThemeContext.tsx
Normal file
155
app/frontend/app/lib/ThemeContext.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
|
||||
/* Theme definitions — mirrors @dangerousthings/tokens types */
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
supportsModes: ThemeMode[];
|
||||
defaultMode: ThemeMode;
|
||||
author?: string;
|
||||
css_url?: string;
|
||||
source?: "builtin" | "plugin";
|
||||
}
|
||||
|
||||
export type ThemeBrand = string; // dynamic — supports plugin themes beyond dt/classic/supra
|
||||
export type ThemeMode = "dark" | "light" | "auto";
|
||||
|
||||
/** Hardcoded fallback themes for SSR / offline / initial render */
|
||||
export const builtinThemes: ThemeDefinition[] = [
|
||||
{
|
||||
id: "dt",
|
||||
name: "Dangerous Things",
|
||||
description: "Official DT brand \u2014 Tektur font, neon cyberpunk, beveled corners",
|
||||
supportsModes: ["dark", "light", "auto"],
|
||||
defaultMode: "dark",
|
||||
},
|
||||
{
|
||||
id: "classic",
|
||||
name: "Classic Cyberpunk",
|
||||
description: "Original dark navy aesthetic with magenta accents",
|
||||
supportsModes: ["dark", "light", "auto"],
|
||||
defaultMode: "dark",
|
||||
},
|
||||
{
|
||||
id: "supra",
|
||||
name: "VivoKey Supra",
|
||||
description: "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
|
||||
supportsModes: ["dark", "light", "auto"],
|
||||
defaultMode: "dark",
|
||||
},
|
||||
];
|
||||
|
||||
/** @deprecated Use builtinThemes instead */
|
||||
export const themes = builtinThemes;
|
||||
|
||||
export const DEFAULT_THEME: ThemeBrand = "dt";
|
||||
|
||||
const BRAND_STORAGE_KEY = "dpi_theme_brand";
|
||||
const MODE_STORAGE_KEY = "theme"; // backward compatible with existing key
|
||||
|
||||
interface ThemeContextValue {
|
||||
brand: ThemeBrand;
|
||||
mode: ThemeMode;
|
||||
setBrand: (brand: ThemeBrand) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
themeDefinition: ThemeDefinition;
|
||||
availableThemes: ThemeDefinition[];
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Inject or update a <link> element for a theme's dynamic token CSS.
|
||||
* Identified by a fixed id so it can be swapped in place.
|
||||
*/
|
||||
function setTokenStylesheet(url: string | undefined) {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const LINK_ID = "dp-theme-tokens";
|
||||
let link = document.getElementById(LINK_ID) as HTMLLinkElement | null;
|
||||
|
||||
if (!url) {
|
||||
link?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (link) {
|
||||
if (link.getAttribute("href") !== url) {
|
||||
link.setAttribute("href", url);
|
||||
}
|
||||
} else {
|
||||
link = document.createElement("link");
|
||||
link.id = LINK_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = url;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [brand, setBrandState] = useState<ThemeBrand>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
return localStorage.getItem(BRAND_STORAGE_KEY) || DEFAULT_THEME;
|
||||
});
|
||||
|
||||
const [mode, setModeState] = useState<ThemeMode>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return (localStorage.getItem(MODE_STORAGE_KEY) as ThemeMode) || "dark";
|
||||
});
|
||||
|
||||
const [availableThemes, setAvailableThemes] = useState<ThemeDefinition[]>(builtinThemes);
|
||||
|
||||
// Fetch dynamic theme list from API on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/system/themes");
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (!cancelled && Array.isArray(data.themes) && data.themes.length > 0) {
|
||||
setAvailableThemes(data.themes);
|
||||
}
|
||||
} catch {
|
||||
// API unavailable — keep fallback themes
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const setBrand = useCallback((newBrand: ThemeBrand) => {
|
||||
setBrandState(newBrand);
|
||||
localStorage.setItem(BRAND_STORAGE_KEY, newBrand);
|
||||
document.documentElement.setAttribute("data-brand", newBrand);
|
||||
}, []);
|
||||
|
||||
const setMode = useCallback((newMode: ThemeMode) => {
|
||||
setModeState(newMode);
|
||||
localStorage.setItem(MODE_STORAGE_KEY, newMode);
|
||||
document.documentElement.setAttribute("data-theme", newMode);
|
||||
}, []);
|
||||
|
||||
// Set DOM attributes + inject token stylesheet when brand/mode/themes change
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-brand", brand);
|
||||
document.documentElement.setAttribute("data-theme", mode);
|
||||
|
||||
const def = availableThemes.find((t) => t.id === brand);
|
||||
setTokenStylesheet(def?.css_url);
|
||||
}, [brand, mode, availableThemes]);
|
||||
|
||||
const themeDefinition = availableThemes.find((t) => t.id === brand) || availableThemes[0] || builtinThemes[0];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ brand, mode, setBrand, setMode, themeDefinition, availableThemes }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -7,27 +7,40 @@ import {
|
||||
ScrollRestoration,
|
||||
useLocation,
|
||||
} from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import styles from "./styles.css?url";
|
||||
import baseStyles from "./styles/base.css?url";
|
||||
import themeStyles from "@dangerousthings/web/dist/index.css?url";
|
||||
import { PowerWidget } from "./components/PowerWidget";
|
||||
import { HeaderWidgets } from "./components/HeaderWidgets";
|
||||
import { LoginPrompt } from "./components/LoginPrompt";
|
||||
import { useWebSocketState } from "./hooks/useWebSocket";
|
||||
import { ThemeProvider, useTheme } from "./lib/ThemeContext";
|
||||
import type { ThemeMode } from "./lib/ThemeContext";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: "stylesheet", href: styles },
|
||||
{ rel: "stylesheet", href: baseStyles },
|
||||
{ rel: "stylesheet", href: themeStyles },
|
||||
{ rel: "preload", href: "/fonts/Tektur-VariableFont_wdth,wght.ttf", as: "font", type: "font/ttf", crossOrigin: "anonymous" },
|
||||
];
|
||||
|
||||
/**
|
||||
* FOUC prevention: static inline script reads localStorage and sets
|
||||
* data-brand/data-theme before first paint. This is a hardcoded string
|
||||
* constant (no user input), so it is safe from XSS.
|
||||
*/
|
||||
const FOUC_PREVENTION_SCRIPT = `(function(){try{var b=localStorage.getItem('dpi_theme_brand')||'dt';var m=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-brand',b);document.documentElement.setAttribute('data-theme',m);}catch(e){}})();`;
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" data-brand="dt" data-theme="dark">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||
<meta name="theme-color" content="#0a0e1a" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Dangerous Pi - Proxmark3 Management Interface" />
|
||||
<Meta />
|
||||
<Links />
|
||||
{/* Prevent FOUC: hardcoded constant string, no user input — safe from XSS */}
|
||||
<script dangerouslySetInnerHTML={{ __html: FOUC_PREVENTION_SCRIPT }} />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
@@ -39,40 +52,30 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppInner />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const location = useLocation();
|
||||
const wsState = useWebSocketState();
|
||||
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
|
||||
const { mode, setMode } = useTheme();
|
||||
|
||||
// Initialize theme (default to dark, honor system preference if available)
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as "auto" | "dark" | "light" | null;
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
document.documentElement.setAttribute("data-theme", savedTheme);
|
||||
} else {
|
||||
// Default to dark for Dangerous Things cyberpunk aesthetic
|
||||
setTheme("dark");
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const themes: Array<"auto" | "dark" | "light"> = ["dark", "auto", "light"];
|
||||
const currentIndex = themes.indexOf(theme);
|
||||
const nextTheme = themes[(currentIndex + 1) % themes.length];
|
||||
|
||||
setTheme(nextTheme);
|
||||
localStorage.setItem("theme", nextTheme);
|
||||
document.documentElement.setAttribute("data-theme", nextTheme);
|
||||
const toggleMode = () => {
|
||||
const modes: ThemeMode[] = ["dark", "auto", "light"];
|
||||
const currentIndex = modes.indexOf(mode);
|
||||
setMode(modes[(currentIndex + 1) % modes.length]);
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: "◈" },
|
||||
{ to: "/commands", label: "Commands", icon: "▶" },
|
||||
{ to: "/settings", label: "Settings", icon: "⚙" },
|
||||
{ to: "/updates", label: "Updates", icon: "🔄" },
|
||||
{ to: "/logs", label: "Logs", icon: "≡" },
|
||||
{ to: "/", label: "Dashboard", icon: "\u25C8" },
|
||||
{ to: "/commands", label: "Commands", icon: "\u25B6" },
|
||||
{ to: "/settings", label: "Settings", icon: "\u2699" },
|
||||
{ to: "/updates", label: "Updates", icon: "\uD83D\uDD04" },
|
||||
{ to: "/logs", label: "Logs", icon: "\u2261" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -87,13 +90,13 @@ export default function App() {
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<PowerWidget />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
onClick={toggleMode}
|
||||
className="btn-secondary"
|
||||
style={{ minWidth: "44px", padding: "0.5rem" }}
|
||||
aria-label="Toggle theme"
|
||||
title={`Current theme: ${theme}`}
|
||||
aria-label="Toggle theme mode"
|
||||
title={`Current mode: ${mode}`}
|
||||
>
|
||||
{theme === "dark" ? "◐" : theme === "light" ? "○" : "◑"}
|
||||
{mode === "dark" ? "\u25D0" : mode === "light" ? "\u25CB" : "\u25D1"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,7 +353,7 @@ export default function Dashboard() {
|
||||
<strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version}
|
||||
</p>
|
||||
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
|
||||
Built for <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
|
||||
Built by <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useActionData, useLoaderData, useNavigation, useFetcher } from "@remix-run/react";
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
import ConnectDialog from "~/components/ConnectDialog";
|
||||
import { useTheme } from "~/lib/ThemeContext";
|
||||
import type { ThemeBrand, ThemeMode } from "~/lib/ThemeContext";
|
||||
|
||||
// Lazy load QR Scanner
|
||||
const QRScanner = lazy(() => import("~/components/QRScanner"));
|
||||
@@ -277,7 +279,8 @@ export default function Settings() {
|
||||
const { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const navigation = useNavigation();
|
||||
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general");
|
||||
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system" | "appearance">("general");
|
||||
const { brand, mode, setBrand, setMode, availableThemes } = useTheme();
|
||||
const [scannedNetworks, setScannedNetworks] = useState<any[]>([]);
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
|
||||
const [showConnectDialog, setShowConnectDialog] = useState(false);
|
||||
@@ -336,6 +339,7 @@ export default function Settings() {
|
||||
{ id: "plugins", label: "Plugins", icon: "🔌" },
|
||||
{ id: "advanced", label: "Advanced", icon: "◈" },
|
||||
{ id: "system", label: "System", icon: "⚡" },
|
||||
{ id: "appearance", label: "Theme", icon: "◐" },
|
||||
];
|
||||
|
||||
const getSignalIcon = (strength: number) => {
|
||||
@@ -348,10 +352,6 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "var(--space-6)" }}>
|
||||
<span style={{ color: "var(--color-secondary)" }}>⚙</span> Settings
|
||||
</h1>
|
||||
|
||||
{actionData && "message" in actionData && (
|
||||
<div
|
||||
className="card"
|
||||
@@ -369,8 +369,11 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section Tabs */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)", padding: "var(--space-2)" }}>
|
||||
{/* Settings Header + Section Tabs */}
|
||||
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<h3 className="card-title">
|
||||
<span>⚙</span> Settings
|
||||
</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "var(--space-2)" }}>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
@@ -775,7 +778,7 @@ export default function Settings() {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: "rgba(10, 14, 26, 0.95)",
|
||||
background: "rgba(var(--color-bg-rgb), 0.95)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -965,6 +968,52 @@ export default function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appearance */}
|
||||
{activeSection === "appearance" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
||||
<div className="card">
|
||||
<h3 className="card-title">Theme</h3>
|
||||
<div className="form-group">
|
||||
<label className="label">Brand</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
{availableThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setBrand(t.id as ThemeBrand)}
|
||||
className={`btn ${brand === t.id ? "btn-primary" : "btn-secondary"}`}
|
||||
style={{ flex: "1 1 auto", minWidth: "120px" }}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
|
||||
{availableThemes.find((t) => t.id === brand)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="label">Mode</label>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
{(["dark", "auto", "light"] as ThemeMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`btn ${mode === m ? "btn-primary" : "btn-secondary"}`}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{m === "dark" ? "\u25D0 Dark" : m === "light" ? "\u25CB Light" : "\u25D1 Auto"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
|
||||
Auto follows your system preference
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Actions */}
|
||||
{activeSection === "system" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,10 @@
|
||||
/* Dangerous Pi - Cyberpunk Theme */
|
||||
/* Dangerous Pi - Base Styles (Theme-Agnostic) */
|
||||
/* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */
|
||||
|
||||
/* ============================================================================
|
||||
Default token values (overridden by theme files via data-brand attribute)
|
||||
============================================================================ */
|
||||
:root {
|
||||
/* Cyberpunk Color Palette - Dark Mode Default */
|
||||
--color-bg: #0a0e1a;
|
||||
--color-surface: #121827;
|
||||
--color-surface-hover: #1a2332;
|
||||
--color-border: #1e293b;
|
||||
|
||||
--color-text-primary: #e2e8f0;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
|
||||
/* Neon Accents */
|
||||
--color-primary: #00ffff; /* Cyan */
|
||||
--color-primary-dim: #0891b2;
|
||||
--color-secondary: #ff00ff; /* Magenta */
|
||||
--color-accent: #00ff88; /* Green */
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #00ff88;
|
||||
--color-warning: #ffaa00;
|
||||
--color-error: #ff0055;
|
||||
--color-info: #00ffff;
|
||||
|
||||
/* Spacing (4px base) */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
@@ -37,57 +18,25 @@
|
||||
--radius: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
|
||||
/* Bevel sizes (DT theme sets real values; Classic/Supra set 0px) */
|
||||
--bevel-sm: 0px;
|
||||
--bevel-md: 0px;
|
||||
--bevel-lg: 0px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition: 250ms ease;
|
||||
|
||||
/* Monospace for terminal feel */
|
||||
/* Font stacks — themes override --font-heading and --font-body */
|
||||
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-body: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Light mode override (if user prefers) */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #0891b2;
|
||||
--color-primary-dim: #0e7490;
|
||||
--color-secondary: #c026d3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Force dark mode */
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Force light mode */
|
||||
:root[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f8fafc;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
--color-primary: #0891b2;
|
||||
--color-primary-dim: #0e7490;
|
||||
--color-secondary: #c026d3;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Reset & Base */
|
||||
/* ============================================================================
|
||||
Reset & Base
|
||||
============================================================================ */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -95,7 +44,7 @@
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -109,17 +58,20 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
/* ============================================================================
|
||||
Typography
|
||||
============================================================================ */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h1 { font-size: 1.875rem; } /* 30px */
|
||||
h2 { font-size: 1.5rem; } /* 24px */
|
||||
h3 { font-size: 1.25rem; } /* 20px */
|
||||
h4 { font-size: 1.125rem; } /* 18px */
|
||||
h1 { font-size: 1.875rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
h3 { font-size: 1.25rem; }
|
||||
h4 { font-size: 1.125rem; }
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-4);
|
||||
@@ -135,7 +87,9 @@ a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
/* ============================================================================
|
||||
Layout
|
||||
============================================================================ */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
@@ -151,7 +105,7 @@ a:hover {
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
background: rgba(18, 24, 39, 0.9);
|
||||
background: rgba(var(--color-surface-rgb), 0.9);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@@ -166,6 +120,7 @@ a:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-primary);
|
||||
@@ -180,7 +135,9 @@ a:hover {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
/* ============================================================================
|
||||
Navigation
|
||||
============================================================================ */
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@@ -204,7 +161,7 @@ a:hover {
|
||||
.nav-link.active {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
@@ -230,7 +187,7 @@ a:hover {
|
||||
}
|
||||
|
||||
body {
|
||||
padding-bottom: 60px; /* Space for bottom nav */
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +198,9 @@ a:hover {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
/* ============================================================================
|
||||
Cards
|
||||
============================================================================ */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -255,6 +214,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-3);
|
||||
@@ -267,7 +227,9 @@ a:hover {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
/* ============================================================================
|
||||
Buttons
|
||||
============================================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -275,6 +237,7 @@ a:hover {
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
@@ -282,21 +245,21 @@ a:hover {
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 44px; /* Touch-friendly */
|
||||
font-family: var(--font-sans);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
|
||||
box-shadow: 0 0 30px rgba(var(--color-accent-rgb), 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -307,19 +270,20 @@ a:hover {
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #cc0044;
|
||||
color: var(--color-bg);
|
||||
filter: brightness(0.85);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -329,7 +293,9 @@ a:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
/* ============================================================================
|
||||
Status Badges
|
||||
============================================================================ */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -343,25 +309,25 @@ a:hover {
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
background: rgba(var(--color-success-rgb), 0.2);
|
||||
color: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: rgba(255, 0, 85, 0.2);
|
||||
background: rgba(var(--color-error-rgb), 0.2);
|
||||
color: var(--color-error);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: rgba(255, 170, 0, 0.2);
|
||||
background: rgba(var(--color-warning-rgb), 0.2);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid var(--color-warning);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: rgba(0, 255, 255, 0.2);
|
||||
background: rgba(var(--color-info-rgb), 0.2);
|
||||
color: var(--color-info);
|
||||
border: 1px solid var(--color-info);
|
||||
}
|
||||
@@ -375,7 +341,9 @@ a:hover {
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
/* ============================================================================
|
||||
Forms
|
||||
============================================================================ */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@@ -406,14 +374,16 @@ a:hover {
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Terminal/Code Output */
|
||||
/* ============================================================================
|
||||
Terminal / Code Output
|
||||
============================================================================ */
|
||||
.terminal {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -446,7 +416,9 @@ a:hover {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
/* ============================================================================
|
||||
Loading States
|
||||
============================================================================ */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -477,7 +449,9 @@ a:hover {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
/* ============================================================================
|
||||
Toast Notifications
|
||||
============================================================================ */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
@@ -502,7 +476,9 @@ a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
/* ============================================================================
|
||||
Utility Classes
|
||||
============================================================================ */
|
||||
.text-primary { color: var(--color-text-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-muted { color: var(--color-text-muted); }
|
||||
@@ -547,7 +523,9 @@ a:hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
/* ============================================================================
|
||||
Responsive
|
||||
============================================================================ */
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.5rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
@@ -561,7 +539,9 @@ a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* Power Widget */
|
||||
/* ============================================================================
|
||||
Power Widget
|
||||
============================================================================ */
|
||||
.power-widget {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -609,12 +589,8 @@ a:hover {
|
||||
}
|
||||
|
||||
@keyframes plug-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Battery Icon */
|
||||
@@ -657,7 +633,7 @@ a:hover {
|
||||
|
||||
.power-widget--charging {
|
||||
color: var(--color-accent);
|
||||
border-color: rgba(0, 255, 136, 0.3);
|
||||
border-color: rgba(var(--color-accent-rgb), 0.3);
|
||||
}
|
||||
|
||||
.power-widget--full {
|
||||
@@ -667,23 +643,23 @@ a:hover {
|
||||
|
||||
.power-widget--low {
|
||||
color: var(--color-warning);
|
||||
border-color: rgba(255, 170, 0, 0.3);
|
||||
border-color: rgba(var(--color-warning-rgb), 0.3);
|
||||
}
|
||||
|
||||
.power-widget--critical {
|
||||
color: var(--color-error);
|
||||
border-color: rgba(255, 0, 85, 0.3);
|
||||
border-color: rgba(var(--color-error-rgb), 0.3);
|
||||
animation: critical-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes critical-pulse {
|
||||
0%, 100% {
|
||||
border-color: rgba(255, 0, 85, 0.3);
|
||||
border-color: rgba(var(--color-error-rgb), 0.3);
|
||||
box-shadow: none;
|
||||
}
|
||||
50% {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
|
||||
box-shadow: 0 0 10px rgba(var(--color-error-rgb), 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,7 +677,6 @@ a:hover {
|
||||
/* ============================================================================
|
||||
Header Widgets - Notification banners below header
|
||||
============================================================================ */
|
||||
|
||||
.header-widgets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -733,25 +708,25 @@ a:hover {
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
background: rgba(0, 255, 255, 0.1);
|
||||
background: rgba(var(--color-info-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-info);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.widget-warning {
|
||||
background: rgba(255, 170, 0, 0.1);
|
||||
background: rgba(var(--color-warning-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.widget-error {
|
||||
background: rgba(255, 0, 85, 0.1);
|
||||
background: rgba(var(--color-error-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.widget-success {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
background: rgba(var(--color-success-rgb), 0.1);
|
||||
border-left: 3px solid var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
@@ -769,7 +744,7 @@ a:hover {
|
||||
|
||||
.widget-action {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -780,7 +755,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.widget-action:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.widget-dismiss {
|
||||
21
app/frontend/package-lock.json
generated
21
app/frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "dangerous-pi-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
@@ -26,6 +27,22 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"../../../dt-design-system/packages/web": {
|
||||
"name": "@dangerousthings/web",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dangerousthings/tokens": "*"
|
||||
}
|
||||
},
|
||||
"../../../dt-web-theme": {
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"../../dt-web-theme": {
|
||||
"extraneous": true
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
@@ -515,6 +532,10 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dangerousthings/web": {
|
||||
"resolved": "../../../dt-design-system/packages/web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
BIN
app/frontend/public/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
BIN
app/frontend/public/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
8
app/frontend/themes/classic/theme.json
Normal file
8
app/frontend/themes/classic/theme.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "classic",
|
||||
"name": "Classic Cyberpunk",
|
||||
"description": "Original dark navy aesthetic with magenta accents",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark",
|
||||
"author": "Dangerous Things"
|
||||
}
|
||||
5
app/frontend/themes/classic/tokens.css
Normal file
5
app/frontend/themes/classic/tokens.css
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
* Classic Cyberpunk — Token CSS
|
||||
*
|
||||
* Generated by @dangerousthings/tokens during CI builds.
|
||||
*/
|
||||
8
app/frontend/themes/dt/theme.json
Normal file
8
app/frontend/themes/dt/theme.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "dt",
|
||||
"name": "Dangerous Things",
|
||||
"description": "Official DT brand — Tektur font, neon cyberpunk, beveled corners",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark",
|
||||
"author": "Dangerous Things"
|
||||
}
|
||||
11
app/frontend/themes/dt/tokens.css
Normal file
11
app/frontend/themes/dt/tokens.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Dangerous Things (dt) — Token CSS
|
||||
*
|
||||
* This file is generated by @dangerousthings/tokens during CI builds.
|
||||
* It contains CSS custom properties for brand colors, typography, and
|
||||
* shape tokens that override the structural base.css defaults.
|
||||
*
|
||||
* In development, the bundled @dangerousthings/web/dist/index.css provides
|
||||
* these tokens. This file is loaded dynamically in production when the
|
||||
* theme component is updated independently of the frontend.
|
||||
*/
|
||||
68
app/plugins/supra_theme/main.py
Normal file
68
app/plugins/supra_theme/main.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""VivoKey Supra Theme plugin for Dangerous Pi.
|
||||
|
||||
Registers the Supra theme (Material Design 3 with VivoKey blue, rounded
|
||||
corners, elevation shadows) via the theme_register hook so it appears in
|
||||
Settings > Appearance when this plugin is enabled.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_plugin_manager_module = (
|
||||
sys.modules.get('app.backend.managers.plugin_manager') or
|
||||
sys.modules.get('backend.managers.plugin_manager')
|
||||
)
|
||||
if _plugin_manager_module:
|
||||
PluginBase = _plugin_manager_module.PluginBase
|
||||
PluginMetadata = _plugin_manager_module.PluginMetadata
|
||||
else:
|
||||
from pathlib import Path as _P
|
||||
app_path = _P(__file__).parent.parent.parent
|
||||
if str(app_path) not in sys.path:
|
||||
sys.path.insert(0, str(app_path))
|
||||
from backend.managers.plugin_manager import PluginBase, PluginMetadata
|
||||
|
||||
# Paths
|
||||
_PLUGIN_DIR = Path(__file__).parent
|
||||
_STATIC_CSS = _PLUGIN_DIR / "static" / "tokens.css"
|
||||
|
||||
# Where the frontend serves themes from
|
||||
_THEMES_DIR = _PLUGIN_DIR.parent.parent / "frontend" / "themes" / "supra"
|
||||
|
||||
|
||||
class SupraThemePlugin(PluginBase):
|
||||
"""Provides the VivoKey Supra theme to Dangerous Pi."""
|
||||
|
||||
async def on_load(self):
|
||||
pass
|
||||
|
||||
async def on_enable(self):
|
||||
# Copy CSS to the themes directory so it's served by the /themes mount
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(_STATIC_CSS, _THEMES_DIR / "tokens.css")
|
||||
|
||||
# Register the theme via the theme_register hook.
|
||||
# MUST be synchronous — the theme scanner skips async callbacks.
|
||||
def supra_theme_register():
|
||||
return {
|
||||
"id": "supra",
|
||||
"name": "VivoKey Supra",
|
||||
"description": "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark",
|
||||
"author": "Dangerous Things",
|
||||
"css_url": "/themes/supra/tokens.css",
|
||||
}
|
||||
|
||||
self.register_hook("theme_register", supra_theme_register)
|
||||
|
||||
async def on_disable(self):
|
||||
# Clean up the copied theme files
|
||||
if _THEMES_DIR.exists():
|
||||
shutil.rmtree(_THEMES_DIR, ignore_errors=True)
|
||||
|
||||
async def on_unload(self):
|
||||
pass
|
||||
|
||||
def get_metadata(self) -> PluginMetadata:
|
||||
return self.metadata
|
||||
10
app/plugins/supra_theme/plugin.json
Normal file
10
app/plugins/supra_theme/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "supra_theme",
|
||||
"name": "VivoKey Supra Theme",
|
||||
"version": "1.0.0",
|
||||
"description": "Material Design 3 theme with VivoKey blue, rounded corners, and elevation shadows",
|
||||
"author": "Dangerous Things",
|
||||
"homepage": "https://github.com/dangerous-tac0s/dt-design-system",
|
||||
"dependencies": [],
|
||||
"permissions": []
|
||||
}
|
||||
232
app/plugins/supra_theme/static/tokens.css
Normal file
232
app/plugins/supra_theme/static/tokens.css
Normal file
@@ -0,0 +1,232 @@
|
||||
/* VivoKey Supra Theme — CSS Custom Properties + Component Overrides */
|
||||
/* Distributed as a Dangerous-Pi theme plugin */
|
||||
/* Source: @dangerousthings/tokens + @dangerousthings/web */
|
||||
|
||||
/* ============================================================================
|
||||
Dark Mode (Default)
|
||||
============================================================================ */
|
||||
:root[data-brand="supra"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #000000;
|
||||
--color-bg-rgb: 0, 0, 0;
|
||||
--color-surface: #28292E;
|
||||
--color-surface-rgb: 40, 41, 46;
|
||||
--color-surface-hover: #303136;
|
||||
--color-border: #42667E;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e2e8f0;
|
||||
--color-text-muted: #94a3b8;
|
||||
|
||||
/* Brand Palette */
|
||||
--color-primary: #42667E;
|
||||
--color-primary-rgb: 66, 102, 126;
|
||||
--color-primary-dim: #385872;
|
||||
--color-secondary: #8BAEC7;
|
||||
--color-secondary-rgb: 139, 174, 199;
|
||||
--color-accent: #8BAEC7;
|
||||
--color-accent-rgb: 139, 174, 199;
|
||||
--color-other: #F67448;
|
||||
--color-other-rgb: 246, 116, 72;
|
||||
|
||||
/* Status */
|
||||
--color-success: #8BB174;
|
||||
--color-success-rgb: 139, 177, 116;
|
||||
--color-warning: #F67448;
|
||||
--color-warning-rgb: 246, 116, 72;
|
||||
--color-error: #ED4337;
|
||||
--color-error-rgb: 237, 67, 55;
|
||||
--color-info: #42667E;
|
||||
--color-info-rgb: 66, 102, 126;
|
||||
|
||||
/* Typography */
|
||||
--font-heading: var(--font-sans);
|
||||
--font-body: var(--font-sans);
|
||||
|
||||
/* Shape */
|
||||
--bevel-sm: 0px;
|
||||
--bevel-md: 0px;
|
||||
--bevel-lg: 0px;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius: 0.625rem;
|
||||
--radius-lg: 0.75rem;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Light Mode
|
||||
============================================================================ */
|
||||
:root[data-brand="supra"][data-theme="light"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-rgb: 255, 255, 255;
|
||||
--color-surface: #f0f2f4;
|
||||
--color-surface-rgb: 240, 242, 244;
|
||||
--color-surface-hover: #e5e8eb;
|
||||
--color-border: #000000;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #374151;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Brand Palette */
|
||||
--color-primary: #42667E;
|
||||
--color-primary-rgb: 66, 102, 126;
|
||||
--color-primary-dim: #385872;
|
||||
--color-secondary: #F67448;
|
||||
--color-secondary-rgb: 246, 116, 72;
|
||||
--color-accent: #b45309;
|
||||
--color-accent-rgb: 180, 83, 9;
|
||||
--color-other: #F67448;
|
||||
--color-other-rgb: 246, 116, 72;
|
||||
|
||||
/* Status */
|
||||
--color-success: #4d7c0f;
|
||||
--color-success-rgb: 77, 124, 15;
|
||||
--color-warning: #F67448;
|
||||
--color-warning-rgb: 246, 116, 72;
|
||||
--color-error: #ED4337;
|
||||
--color-error-rgb: 237, 67, 55;
|
||||
--color-info: #42667E;
|
||||
--color-info-rgb: 66, 102, 126;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Auto Mode (follows system preference)
|
||||
============================================================================ */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-brand="supra"][data-theme="auto"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-rgb: 255, 255, 255;
|
||||
--color-surface: #f0f2f4;
|
||||
--color-surface-rgb: 240, 242, 244;
|
||||
--color-surface-hover: #e5e8eb;
|
||||
--color-border: #000000;
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #374151;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Brand Palette */
|
||||
--color-primary: #42667E;
|
||||
--color-primary-rgb: 66, 102, 126;
|
||||
--color-primary-dim: #385872;
|
||||
--color-secondary: #F67448;
|
||||
--color-secondary-rgb: 246, 116, 72;
|
||||
--color-accent: #b45309;
|
||||
--color-accent-rgb: 180, 83, 9;
|
||||
--color-other: #F67448;
|
||||
--color-other-rgb: 246, 116, 72;
|
||||
|
||||
/* Status */
|
||||
--color-success: #4d7c0f;
|
||||
--color-success-rgb: 77, 124, 15;
|
||||
--color-warning: #F67448;
|
||||
--color-warning-rgb: 246, 116, 72;
|
||||
--color-error: #ED4337;
|
||||
--color-error-rgb: 237, 67, 55;
|
||||
--color-info: #42667E;
|
||||
--color-info-rgb: 66, 102, 126;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Supra Component Overrides — MD3 Elevation & Rounded Corners
|
||||
============================================================================ */
|
||||
|
||||
/* Card Elevation */
|
||||
[data-brand="supra"] .card {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
/* Button — Dual-layer inset shadow (hardware button feel) */
|
||||
[data-brand="supra"] .btn-primary {
|
||||
box-shadow:
|
||||
inset -2px -2px 4px rgba(0, 0, 0, 0.18),
|
||||
inset 2px 2px 4px rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .btn-primary:hover {
|
||||
box-shadow:
|
||||
inset -2px -2px 6px rgba(0, 0, 0, 0.22),
|
||||
inset 2px 2px 6px rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .btn-secondary {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .btn-danger {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Badge — Rounded pill (MD3 chip style) */
|
||||
[data-brand="supra"] .badge {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Badge text — readable on translucent backgrounds */
|
||||
[data-brand="supra"] .badge-success,
|
||||
[data-brand="supra"] .badge-error,
|
||||
[data-brand="supra"] .badge-warning,
|
||||
[data-brand="supra"] .badge-info {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Input — Rounded with standard focus ring */
|
||||
[data-brand="supra"] .input,
|
||||
[data-brand="supra"] input,
|
||||
[data-brand="supra"] textarea,
|
||||
[data-brand="supra"] select {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
[data-brand="supra"] .input:focus,
|
||||
[data-brand="supra"] input:focus,
|
||||
[data-brand="supra"] textarea:focus,
|
||||
[data-brand="supra"] select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Terminal — Rounded with subtle elevation */
|
||||
[data-brand="supra"] .terminal {
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Elevation Utility Classes */
|
||||
.supra-elevation-1 {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.supra-elevation-2 {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.supra-elevation-3 {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.supra-elevation-4 {
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.supra-elevation-5 {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
192
build-image.sh
192
build-image.sh
@@ -1,5 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Build Dangerous Pi image using pi-gen
|
||||
#
|
||||
# For faster builds, you can use pre-built PM3 binaries:
|
||||
# PM3_TARBALL=/path/to/pm3-*.tar.gz ./build-image.sh full
|
||||
#
|
||||
# For faster package downloads, run apt-cacher-ng locally:
|
||||
# docker run -d -p 3142:3142 --name apt-cache sameersbn/apt-cacher-ng
|
||||
# APT_PROXY=http://host.docker.internal:3142 ./build-image.sh full
|
||||
#
|
||||
# To skip base OS build (stages 0-2), use a pre-baked base image:
|
||||
# BASE_IMAGE=/path/to/base-image.tar.gz ./build-image.sh full
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
@@ -10,12 +21,11 @@ NC='\033[0m' # No Color
|
||||
# Configuration
|
||||
PI_GEN_DIR="/home/work/pi-gen-builder" # Official pi-gen builder
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PM3_STAGE_DIR="$SCRIPT_DIR/pi-gen/stagePM3"
|
||||
DTPI_STAGE_DIR="$SCRIPT_DIR/pi-gen/stageDangerousPi"
|
||||
CONFIG_FILE="$SCRIPT_DIR/pi-gen/config"
|
||||
KEEP_CONTAINER=0
|
||||
|
||||
# Parse flags
|
||||
# Parse flags (after build type positional arg)
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--keep-container)
|
||||
@@ -57,7 +67,7 @@ build_frontend() {
|
||||
echo -e "${GREEN}Running npm build...${NC}"
|
||||
npm run build
|
||||
|
||||
echo -e "${GREEN}✓ Frontend build complete${NC}"
|
||||
echo -e "${GREEN}Frontend build complete${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
return 0
|
||||
}
|
||||
@@ -69,10 +79,6 @@ copy_stages() {
|
||||
# Copy config
|
||||
cp "$CONFIG_FILE" config
|
||||
|
||||
# Copy PM3 stage
|
||||
rm -rf stagePM3
|
||||
cp -r "$PM3_STAGE_DIR" .
|
||||
|
||||
# Copy DangerousPi stage
|
||||
rm -rf stageDangerousPi
|
||||
cp -r "$DTPI_STAGE_DIR" .
|
||||
@@ -92,11 +98,56 @@ copy_stages() {
|
||||
|
||||
# Copy Dangerous Pi source files into the stage
|
||||
echo -e "${GREEN}Copying Dangerous Pi source files...${NC}"
|
||||
mkdir -p stageDangerousPi/03-dangerous-pi/files
|
||||
cp -r "$SCRIPT_DIR/app" stageDangerousPi/03-dangerous-pi/files/
|
||||
cp -r "$SCRIPT_DIR/systemd" stageDangerousPi/03-dangerous-pi/files/
|
||||
cp -r "$SCRIPT_DIR/scripts" stageDangerousPi/03-dangerous-pi/files/
|
||||
cp "$SCRIPT_DIR/requirements.txt" stageDangerousPi/03-dangerous-pi/files/
|
||||
mkdir -p stageDangerousPi/04-dangerous-pi/files
|
||||
cp -r "$SCRIPT_DIR/app" stageDangerousPi/04-dangerous-pi/files/
|
||||
cp -r "$SCRIPT_DIR/systemd" stageDangerousPi/04-dangerous-pi/files/
|
||||
cp -r "$SCRIPT_DIR/scripts" stageDangerousPi/04-dangerous-pi/files/
|
||||
cp "$SCRIPT_DIR/requirements.txt" stageDangerousPi/04-dangerous-pi/files/
|
||||
|
||||
# Copy dt-design-system packages (needed by frontend's file: dependency)
|
||||
DT_DS_DIR="$(dirname "$SCRIPT_DIR")/dt-design-system"
|
||||
if [ -d "$DT_DS_DIR/packages/web" ]; then
|
||||
echo -e "${GREEN}Copying dt-design-system packages...${NC}"
|
||||
mkdir -p stageDangerousPi/04-dangerous-pi/files/dt-design-system/packages
|
||||
cp -r "$DT_DS_DIR/packages/web" stageDangerousPi/04-dangerous-pi/files/dt-design-system/packages/
|
||||
cp -r "$DT_DS_DIR/packages/tokens" stageDangerousPi/04-dangerous-pi/files/dt-design-system/packages/
|
||||
cp "$DT_DS_DIR/package.json" stageDangerousPi/04-dangerous-pi/files/dt-design-system/
|
||||
else
|
||||
echo -e "${YELLOW}WARNING: dt-design-system not found at $DT_DS_DIR${NC}"
|
||||
echo -e "${YELLOW}Frontend npm ci will fail without it${NC}"
|
||||
fi
|
||||
|
||||
# Pass PM3_TARBALL into the stage if set
|
||||
if [ -n "${PM3_TARBALL:-}" ] && [ -f "$PM3_TARBALL" ]; then
|
||||
echo -e "${GREEN}Staging PM3 tarball: $PM3_TARBALL${NC}"
|
||||
mkdir -p stageDangerousPi/02-pm3-install/files
|
||||
cp "$PM3_TARBALL" stageDangerousPi/02-pm3-install/files/
|
||||
fi
|
||||
}
|
||||
|
||||
# Install pre-baked base image if BASE_IMAGE is set
|
||||
install_base_image() {
|
||||
if [ -z "${BASE_IMAGE:-}" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$BASE_IMAGE" ]; then
|
||||
echo -e "${YELLOW}WARNING: BASE_IMAGE file not found: $BASE_IMAGE${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Installing pre-baked base image: $BASE_IMAGE${NC}"
|
||||
cd "$PI_GEN_DIR"
|
||||
|
||||
# Extract base image into work directory as stage2 rootfs
|
||||
local WORK_DIR="work/${IMG_NAME:-dangerous-pi}"
|
||||
mkdir -p "${WORK_DIR}/stage2"
|
||||
tar -xzf "$BASE_IMAGE" -C "${WORK_DIR}/stage2/"
|
||||
echo -e "${GREEN}Base image extracted, skipping stages 0-2${NC}"
|
||||
|
||||
# Mark stages 0-2 as SKIP
|
||||
touch stage0/SKIP stage1/SKIP stage2/SKIP
|
||||
return 0
|
||||
}
|
||||
|
||||
# Build type
|
||||
@@ -114,49 +165,36 @@ case "$BUILD_TYPE" in
|
||||
|
||||
copy_stages
|
||||
|
||||
# Use pre-baked base image if available, otherwise build all stages
|
||||
if install_base_image; then
|
||||
echo -e "${GREEN}Using pre-baked base image (stages 0-2 skipped)${NC}"
|
||||
else
|
||||
# Remove any SKIP files to ensure full build
|
||||
rm -f stage0/SKIP stage1/SKIP stage2/SKIP stagePM3/SKIP stageDangerousPi/SKIP
|
||||
rm -f stage0/SKIP stage1/SKIP stage2/SKIP
|
||||
fi
|
||||
rm -f stageDangerousPi/SKIP
|
||||
|
||||
# Full build (uses docker, no root needed)
|
||||
PIGEN_DOCKER_MODE=1 ./build-docker.sh
|
||||
;;
|
||||
|
||||
from-pm3)
|
||||
echo -e "${GREEN}Building from PM3 stage (PM3 + DangerousPi)...${NC}"
|
||||
copy_stages
|
||||
|
||||
# Skip early stages if cached
|
||||
if ls work/*/stage2/*.qcow2 2>/dev/null | grep -q .; then
|
||||
echo -e "${GREEN}Found cached stage2, creating SKIP markers...${NC}"
|
||||
touch stage0/SKIP stage1/SKIP stage2/SKIP
|
||||
echo -e "${GREEN}Will build: stagePM3 → stageDangerousPi${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}No cached stages found - will build all stages${NC}"
|
||||
rm -f stage0/SKIP stage1/SKIP stage2/SKIP
|
||||
fi
|
||||
|
||||
# Ensure PM3 and DangerousPi stages build
|
||||
rm -f stagePM3/SKIP stageDangerousPi/SKIP
|
||||
|
||||
# Build with continue flag
|
||||
CONTINUE=1 PIGEN_DOCKER_MODE=1 ./build-docker.sh
|
||||
;;
|
||||
|
||||
from-dtpi)
|
||||
echo -e "${GREEN}Building from DangerousPi stage (customizations only)...${NC}"
|
||||
copy_stages
|
||||
|
||||
# Check for cached PM3 stage
|
||||
if ls work/*/stagePM3/*.qcow2 2>/dev/null | grep -q .; then
|
||||
echo -e "${GREEN}Found cached stagePM3, creating SKIP markers...${NC}"
|
||||
touch stage0/SKIP stage1/SKIP stage2/SKIP stagePM3/SKIP
|
||||
echo -e "${GREEN}Will build: stageDangerousPi only (~5 min)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}WARNING: No cached PM3 stage found!${NC}"
|
||||
echo -e "${YELLOW}You need to run './build-image.sh full' or './build-image.sh from-pm3' first${NC}"
|
||||
echo -e "${YELLOW}Falling back to full build from PM3...${NC}"
|
||||
# Check for cached stage2
|
||||
if ls work/*/stage2/*.qcow2 2>/dev/null | grep -q . || \
|
||||
[ -d "work/dangerous-pi/stage2/rootfs" ]; then
|
||||
echo -e "${GREEN}Found cached base stages, creating SKIP markers...${NC}"
|
||||
touch stage0/SKIP stage1/SKIP stage2/SKIP
|
||||
rm -f stagePM3/SKIP
|
||||
echo -e "${GREEN}Will build: stageDangerousPi only (~5 min)${NC}"
|
||||
elif install_base_image; then
|
||||
echo -e "${GREEN}Using pre-baked base image${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}WARNING: No cached stages found!${NC}"
|
||||
echo -e "${YELLOW}You need to run './build-image.sh full' first${NC}"
|
||||
echo -e "${YELLOW}Falling back to full build...${NC}"
|
||||
rm -f stage0/SKIP stage1/SKIP stage2/SKIP
|
||||
fi
|
||||
|
||||
# Ensure DangerousPi stage builds
|
||||
@@ -170,10 +208,10 @@ case "$BUILD_TYPE" in
|
||||
echo -e "${YELLOW}Removing ALL build cache and container...${NC}"
|
||||
if docker ps -a --filter name=pigen_work --format "{{.Names}}" | grep -q pigen_work; then
|
||||
docker rm -v pigen_work > /dev/null 2>&1 || true
|
||||
echo -e "${GREEN}✓ Removed container and volume${NC}"
|
||||
echo -e "${GREEN}Removed container and volume${NC}"
|
||||
else
|
||||
docker volume rm pigen_work > /dev/null 2>&1 || true
|
||||
echo -e "${GREEN}✓ Removed volume${NC}"
|
||||
echo -e "${GREEN}Removed volume${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo "Cache wiped. Next build will be from scratch."
|
||||
@@ -183,46 +221,50 @@ case "$BUILD_TYPE" in
|
||||
test)
|
||||
echo -e "${GREEN}Validating stage scripts...${NC}"
|
||||
|
||||
# Syntax check - PM3 build
|
||||
bash -n "$PM3_STAGE_DIR/01-proxmark3/00-run-chroot.sh"
|
||||
# Syntax check - PM3 install
|
||||
bash -n "$DTPI_STAGE_DIR/02-pm3-install/00-run.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/02-pm3-install/00-run-chroot.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/02-pm3-install/00-run-chroot-compile.sh"
|
||||
|
||||
# Syntax check - WiFi AP
|
||||
bash -n "$DTPI_STAGE_DIR/01-Wireless-AP/00-run-chroot.sh"
|
||||
|
||||
# Syntax check - ttyd
|
||||
bash -n "$DTPI_STAGE_DIR/02-ttyd/00-run-chroot.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/03-ttyd/00-run-chroot.sh"
|
||||
|
||||
# Syntax check - Dangerous Pi
|
||||
bash -n "$DTPI_STAGE_DIR/03-dangerous-pi/00-run.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/03-dangerous-pi/00-run-chroot.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/03-dangerous-pi/01-run-chroot.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/04-dangerous-pi/00-run.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/04-dangerous-pi/00-run-chroot.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/04-dangerous-pi/01-run-chroot.sh"
|
||||
|
||||
echo -e "${GREEN}✓ All scripts pass syntax validation${NC}"
|
||||
# Syntax check - OS updates
|
||||
if [ -f "$DTPI_STAGE_DIR/07-os-updates/00-run-chroot.sh" ]; then
|
||||
bash -n "$DTPI_STAGE_DIR/07-os-updates/00-run-chroot.sh"
|
||||
bash -n "$DTPI_STAGE_DIR/07-os-updates/01-run-chroot.sh"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}All scripts pass syntax validation${NC}"
|
||||
|
||||
# Check required files exist
|
||||
[ -d "$(dirname "$0")/app" ] || { echo "Error: app/ directory not found"; exit 1; }
|
||||
[ -f "$(dirname "$0")/requirements.txt" ] || { echo "Error: requirements.txt not found"; exit 1; }
|
||||
[ -d "$(dirname "$0")/systemd" ] || { echo "Error: systemd/ directory not found"; exit 1; }
|
||||
|
||||
echo -e "${GREEN}✓ All required files exist${NC}"
|
||||
echo -e "${GREEN}All required files exist${NC}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 {full|from-pm3|from-dtpi|test|clean} [--keep-container]"
|
||||
echo "Usage: $0 {full|from-dtpi|test|clean} [--keep-container]"
|
||||
echo ""
|
||||
echo "Build Commands:"
|
||||
echo " full - Build all stages (~1 hour first time, cached after)"
|
||||
echo " full - Build all stages (~15-20 min with pre-built PM3, cached base)"
|
||||
echo " Use for: First build or when base system needs updates"
|
||||
echo " ✓ Preserves cache for fast rebuilds"
|
||||
echo " Preserves cache for fast rebuilds"
|
||||
echo ""
|
||||
echo " from-pm3 - Rebuild PM3 + customizations (~15 min with cache)"
|
||||
echo " Use for: Updating Proxmark3 firmware/client"
|
||||
echo " Skips: stage0, stage1, stage2 (uses cache)"
|
||||
echo ""
|
||||
echo " from-dtpi - Rebuild only customizations (~5 min) ⭐"
|
||||
echo " from-dtpi - Rebuild only customizations (~5 min)"
|
||||
echo " Use for: Daily development, app changes"
|
||||
echo " Skips: stage0, stage1, stage2, stagePM3 (uses cache)"
|
||||
echo " Requires: Previous 'full' or 'from-pm3' build"
|
||||
echo " Skips: stages 0-2, PM3 (uses cache)"
|
||||
echo " Requires: Previous 'full' build"
|
||||
echo ""
|
||||
echo "Utility Commands:"
|
||||
echo " test - Validate all scripts without building"
|
||||
@@ -230,16 +272,22 @@ case "$BUILD_TYPE" in
|
||||
echo ""
|
||||
echo "Flags:"
|
||||
echo " --keep-container - Keep Docker container after build (for debugging)"
|
||||
echo " Default: Auto-cleanup container, preserve cache"
|
||||
echo ""
|
||||
echo "Cache Info:"
|
||||
echo " All builds now preserve cache in Docker volume 'pigen_work'"
|
||||
echo " Use 'clean' only if you need to completely start over"
|
||||
echo "Environment Variables:"
|
||||
echo " PM3_TARBALL Path to pre-built PM3 tarball (aarch64)"
|
||||
echo " Skips PM3 compilation (~45 min savings)"
|
||||
echo " Example: PM3_TARBALL=./pm3-v4.20728-aarch64-linux-cp312.tar.gz"
|
||||
echo ""
|
||||
echo " BASE_IMAGE Path to pre-baked base image tarball (stages 0-2)"
|
||||
echo " Skips base OS build (~30 min savings on cold build)"
|
||||
echo " Example: BASE_IMAGE=./base-image-trixie-arm64.tar.gz"
|
||||
echo ""
|
||||
echo " APT_PROXY URL of apt-cacher-ng proxy for faster package downloads"
|
||||
echo " Example: APT_PROXY=http://host.docker.internal:3142"
|
||||
echo ""
|
||||
echo "Stage Structure:"
|
||||
echo " stage0, stage1, stage2 → Base Raspberry Pi OS (cached)"
|
||||
echo " stagePM3 → Proxmark3 build (cached)"
|
||||
echo " stageDangerousPi → WiFi AP + ttyd + your app"
|
||||
echo " stage0, stage1, stage2 -> Base Raspberry Pi OS (cached)"
|
||||
echo " stageDangerousPi -> PM3 + WiFi AP + ttyd + app + UPS + HTTPS + updates"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -252,7 +300,7 @@ if [ $KEEP_CONTAINER -eq 0 ]; then
|
||||
if docker ps -a --filter name=pigen_work --format "{{.Names}}" | grep -q pigen_work; then
|
||||
echo -e "${GREEN}Cleaning up build container (preserving cache)...${NC}"
|
||||
docker rm pigen_work > /dev/null 2>&1
|
||||
echo -e "${GREEN}✓ Container removed, cache preserved for fast rebuilds${NC}"
|
||||
echo -e "${GREEN}Container removed, cache preserved for fast rebuilds${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Container kept for debugging (--keep-container flag)${NC}"
|
||||
|
||||
42
ci/build-backend.sh
Executable file
42
ci/build-backend.sh
Executable file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# Package the backend Python source for distribution.
|
||||
#
|
||||
# Environment variables:
|
||||
# OUTPUT_DIR - where to write the tarball (default: ./dist)
|
||||
# VERSION - version string (default: read from VERSION file)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORKSPACE="${SCRIPT_DIR}/.."
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-${WORKSPACE}/dist}"
|
||||
VERSION="${VERSION:-$(cat "${WORKSPACE}/VERSION" 2>/dev/null || echo "0.1.0")}"
|
||||
|
||||
echo "=== Dangerous Pi Backend Package ==="
|
||||
echo "Version: ${VERSION}"
|
||||
|
||||
STAGING="/tmp/backend-staging/backend"
|
||||
rm -rf /tmp/backend-staging
|
||||
mkdir -p "$STAGING"
|
||||
|
||||
# Copy backend source
|
||||
cp -r "${WORKSPACE}/app/backend" "${STAGING}/app/backend"
|
||||
|
||||
# Copy requirements
|
||||
cp "${WORKSPACE}/requirements.txt" "${STAGING}/"
|
||||
|
||||
cat > "${STAGING}/COMPONENT_VERSION" << EOF
|
||||
{
|
||||
"component_id": "backend",
|
||||
"version": "${VERSION}",
|
||||
"build_date": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
TARBALL="backend-${VERSION}.tar.gz"
|
||||
cd /tmp/backend-staging
|
||||
tar -czf "${OUTPUT_DIR}/${TARBALL}" backend/
|
||||
echo "✅ Built: ${OUTPUT_DIR}/${TARBALL}"
|
||||
|
||||
rm -rf /tmp/backend-staging
|
||||
51
ci/build-frontend.sh
Executable file
51
ci/build-frontend.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Remix frontend and package it for distribution.
|
||||
#
|
||||
# Environment variables:
|
||||
# OUTPUT_DIR - where to write the tarball (default: ./dist)
|
||||
# VERSION - version string (default: read from VERSION file)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORKSPACE="${SCRIPT_DIR}/.."
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-${WORKSPACE}/dist}"
|
||||
VERSION="${VERSION:-$(cat "${WORKSPACE}/VERSION" 2>/dev/null || echo "0.1.0")}"
|
||||
FRONTEND_DIR="${WORKSPACE}/app/frontend"
|
||||
|
||||
echo "=== Dangerous Pi Frontend Build ==="
|
||||
echo "Version: ${VERSION}"
|
||||
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
# Install dependencies (including dev for build)
|
||||
npm ci --production=false
|
||||
|
||||
# Build Remix app
|
||||
npm run build
|
||||
|
||||
# Remove dev dependencies after build
|
||||
npm prune --production
|
||||
|
||||
# Package
|
||||
STAGING="/tmp/frontend-staging/frontend"
|
||||
rm -rf /tmp/frontend-staging
|
||||
mkdir -p "$STAGING"
|
||||
|
||||
cp -r build/ "$STAGING/"
|
||||
|
||||
cat > "${STAGING}/COMPONENT_VERSION" << EOF
|
||||
{
|
||||
"component_id": "frontend",
|
||||
"version": "${VERSION}",
|
||||
"build_date": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
TARBALL="frontend-${VERSION}.tar.gz"
|
||||
cd /tmp/frontend-staging
|
||||
tar -czf "${OUTPUT_DIR}/${TARBALL}" frontend/
|
||||
echo "✅ Built: ${OUTPUT_DIR}/${TARBALL}"
|
||||
|
||||
rm -rf /tmp/frontend-staging
|
||||
169
ci/build-pm3.sh
Executable file
169
ci/build-pm3.sh
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build Proxmark3 client + firmware + Python bindings for aarch64.
|
||||
# Designed to run inside an aarch64 Docker container (via QEMU on CI).
|
||||
#
|
||||
# Environment variables:
|
||||
# PYTHON_VERSION - e.g. "3.12" (default: auto-detect)
|
||||
# PM3_REPO - upstream repo (default: RfidResearchGroup/proxmark3)
|
||||
# OUTPUT_DIR - where to write the tarball (default: ./dist)
|
||||
#
|
||||
# Reads patches from pi-gen/stageDangerousPi/02-pm3-install/ in the workspace.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORKSPACE="${SCRIPT_DIR}/.."
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')}"
|
||||
PYTHON_ABI="cp$(echo "$PYTHON_VERSION" | tr -d '.')"
|
||||
PM3_REPO="${PM3_REPO:-https://github.com/RfidResearchGroup/proxmark3}"
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-${WORKSPACE}/dist}"
|
||||
ARCH="$(uname -m)" # aarch64 when in container
|
||||
BUILD_DIR="/tmp/pm3-build"
|
||||
|
||||
echo "=== Dangerous Pi PM3 Build ==="
|
||||
echo "Python: ${PYTHON_VERSION} (${PYTHON_ABI})"
|
||||
echo "Arch: ${ARCH}"
|
||||
echo "Repo: ${PM3_REPO}"
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 1. Install build dependencies
|
||||
# -----------------------------------------------------------------------
|
||||
echo "--- Installing build dependencies ---"
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
cmake python3-dev swig build-essential \
|
||||
libreadline-dev libusb-1.0-0-dev liblz4-dev libbz2-dev \
|
||||
libjansson-dev libc6-dev pkg-config git \
|
||||
gcc-arm-none-eabi libnewlib-dev \
|
||||
ca-certificates libssl-dev libbluetooth-dev libgd-dev
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 2. Clone and patch
|
||||
# -----------------------------------------------------------------------
|
||||
echo "--- Cloning Proxmark3 ---"
|
||||
rm -rf "$BUILD_DIR"
|
||||
git clone --depth 1 "$PM3_REPO" "$BUILD_DIR"
|
||||
cd "$BUILD_DIR"
|
||||
|
||||
PATCH_DIR="${WORKSPACE}/pi-gen/stageDangerousPi/02-pm3-install"
|
||||
|
||||
# Apply LED PWM control patch
|
||||
if [ -f "${PATCH_DIR}/led-pwm-control-fixed.patch" ]; then
|
||||
echo "Applying LED PWM control patch (fixed)..."
|
||||
patch -p1 < "${PATCH_DIR}/led-pwm-control-fixed.patch" || \
|
||||
echo "Warning: LED PWM patch failed, continuing"
|
||||
elif [ -f "${PATCH_DIR}/led-pwm-control.patch" ]; then
|
||||
echo "Applying LED PWM control patch..."
|
||||
patch -p1 < "${PATCH_DIR}/led-pwm-control.patch" || \
|
||||
echo "Warning: LED PWM patch failed, continuing"
|
||||
fi
|
||||
|
||||
# Apply HF booster detection patch
|
||||
if [ -f "${PATCH_DIR}/hf-booster-detection.patch" ]; then
|
||||
echo "Applying HF booster detection patch..."
|
||||
patch -p1 < "${PATCH_DIR}/hf-booster-detection.patch" || \
|
||||
echo "Warning: HF booster patch failed, continuing"
|
||||
fi
|
||||
|
||||
# DangerousPi version branding
|
||||
sed -i 's/^fullgitinfo="Iceman"$/fullgitinfo="Iceman\/DangerousPi"/' tools/mkversion.sh 2>/dev/null || true
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 3. Configure platform
|
||||
# -----------------------------------------------------------------------
|
||||
cat > Makefile.platform << EOF
|
||||
PLATFORM=PM3GENERIC
|
||||
LED_ORDER=PM3EASY
|
||||
EOF
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 4. Build firmware (bootrom + fullimage)
|
||||
# -----------------------------------------------------------------------
|
||||
echo "--- Building firmware ---"
|
||||
make -j"$(nproc)" bootrom fullimage
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 5. Build client
|
||||
# -----------------------------------------------------------------------
|
||||
echo "--- Building client ---"
|
||||
make -j"$(nproc)" client
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 6. Build Python bindings (SWIG)
|
||||
# -----------------------------------------------------------------------
|
||||
echo "--- Building Python bindings ---"
|
||||
|
||||
# Fix CMakeLists.txt for experimental_lib (add missing sources)
|
||||
if [ -f client/experimental_lib/CMakeLists.txt ]; then
|
||||
sed -i '/TARGET_SOURCES.*pm3rrg_rdv4_experimental/,/)/s|\(${PM3_ROOT}/client/src/wiegand_formatutils.c\)|\1\n ${PM3_ROOT}/client/src/qrcode/qrcode.c|' \
|
||||
client/experimental_lib/CMakeLists.txt 2>/dev/null || true
|
||||
sed -i 's|\(${PM3_ROOT}/client/src/pm3\.c\)|${PM3_ROOT}/client/src/pla.c\n \1|' \
|
||||
client/experimental_lib/CMakeLists.txt 2>/dev/null || true
|
||||
fi
|
||||
|
||||
mkdir -p client/build && cd client/build
|
||||
cmake .. -DBUILD_PYTHON_LIB=ON
|
||||
make -j"$(nproc)"
|
||||
cd ../..
|
||||
|
||||
# Build SWIG library
|
||||
if [ -f client/experimental_lib/00make_swig.sh ]; then
|
||||
cd client/experimental_lib
|
||||
./00make_swig.sh
|
||||
./01make_lib.sh
|
||||
cd ../..
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 7. Package artifacts
|
||||
# -----------------------------------------------------------------------
|
||||
echo "--- Packaging ---"
|
||||
PM3_VERSION="$(git describe --always --tags 2>/dev/null || echo unknown)"
|
||||
STAGING="/tmp/pm3-staging/pm3"
|
||||
rm -rf /tmp/pm3-staging
|
||||
mkdir -p "${STAGING}/client/pyscripts" \
|
||||
"${STAGING}/firmware" \
|
||||
"${STAGING}/patches" \
|
||||
"${STAGING}/scripts"
|
||||
|
||||
# Client binary
|
||||
cp client/build/proxmark3 "${STAGING}/client/"
|
||||
|
||||
# Python bindings
|
||||
cp client/experimental_lib/build/libpm3rrg_rdv4.so "${STAGING}/client/pyscripts/" 2>/dev/null || true
|
||||
cp client/pyscripts/*.py "${STAGING}/client/pyscripts/" 2>/dev/null || true
|
||||
cd "${STAGING}/client/pyscripts" && ln -sf libpm3rrg_rdv4.so _pm3.so && cd "$BUILD_DIR"
|
||||
|
||||
# Firmware
|
||||
cp bootrom/obj/bootrom.elf "${STAGING}/firmware/"
|
||||
cp armsrc/obj/fullimage.elf "${STAGING}/firmware/"
|
||||
|
||||
# Flash utilities
|
||||
cp pm3-flash-all pm3-flash-bootrom pm3-flash-fullimage "${STAGING}/" 2>/dev/null || true
|
||||
chmod +x "${STAGING}"/pm3-flash-* 2>/dev/null || true
|
||||
|
||||
# Patches (for rebuild-from-source fallback)
|
||||
cp "${PATCH_DIR}"/*.patch "${STAGING}/patches/" 2>/dev/null || true
|
||||
|
||||
# Version metadata
|
||||
cat > "${STAGING}/COMPONENT_VERSION" << VEOF
|
||||
{
|
||||
"component_id": "pm3",
|
||||
"version": "${PM3_VERSION}",
|
||||
"platform": "${ARCH}-linux",
|
||||
"python_version": "${PYTHON_VERSION}",
|
||||
"python_abi": "${PYTHON_ABI}",
|
||||
"build_date": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"upstream_commit": "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
}
|
||||
VEOF
|
||||
|
||||
# Create tarball
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
TARBALL="pm3-${PM3_VERSION}-${ARCH}-linux-${PYTHON_ABI}.tar.gz"
|
||||
cd /tmp/pm3-staging
|
||||
tar -czf "${OUTPUT_DIR}/${TARBALL}" pm3/
|
||||
echo "✅ Built: ${OUTPUT_DIR}/${TARBALL}"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$BUILD_DIR" /tmp/pm3-staging
|
||||
100
ci/build-theme.sh
Executable file
100
ci/build-theme.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the theme component from the @dangerousthings/tokens package.
|
||||
# Generates token CSS files and packages them for distribution.
|
||||
#
|
||||
# Environment variables:
|
||||
# OUTPUT_DIR - where to write the tarball (default: ./dist)
|
||||
# VERSION - version string (default: read from VERSION file)
|
||||
# DESIGN_SYSTEM_DIR - path to dt-design-system monorepo
|
||||
# (default: ../dt-design-system relative to workspace)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
WORKSPACE="${SCRIPT_DIR}/.."
|
||||
OUTPUT_DIR="${OUTPUT_DIR:-${WORKSPACE}/dist}"
|
||||
VERSION="${VERSION:-$(cat "${WORKSPACE}/VERSION" 2>/dev/null || echo "0.1.0")}"
|
||||
DESIGN_SYSTEM_DIR="${DESIGN_SYSTEM_DIR:-$(realpath "${WORKSPACE}/../dt-design-system")}"
|
||||
|
||||
echo "=== Dangerous Pi Theme Build ==="
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Design system: ${DESIGN_SYSTEM_DIR}"
|
||||
|
||||
if [ ! -d "$DESIGN_SYSTEM_DIR" ]; then
|
||||
echo "Error: dt-design-system not found at ${DESIGN_SYSTEM_DIR}"
|
||||
echo "Set DESIGN_SYSTEM_DIR to the monorepo path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build tokens to generate CSS
|
||||
cd "$DESIGN_SYSTEM_DIR"
|
||||
npm ci --production=false
|
||||
npm run build:tokens
|
||||
|
||||
# Package theme CSS files
|
||||
STAGING="/tmp/theme-staging/theme"
|
||||
rm -rf /tmp/theme-staging
|
||||
|
||||
BRANDS=("dt" "classic" "supra")
|
||||
for brand in "${BRANDS[@]}"; do
|
||||
CSS_FILE="${DESIGN_SYSTEM_DIR}/packages/tokens/dist/css/${brand}.css"
|
||||
if [ -f "$CSS_FILE" ]; then
|
||||
mkdir -p "${STAGING}/${brand}"
|
||||
cp "$CSS_FILE" "${STAGING}/${brand}/tokens.css"
|
||||
|
||||
# Read brand metadata from the generated CSS or tokens
|
||||
cat > "${STAGING}/${brand}/theme.json" << TJEOF
|
||||
{
|
||||
"id": "${brand}",
|
||||
"supportsModes": ["dark", "light", "auto"],
|
||||
"defaultMode": "dark"
|
||||
}
|
||||
TJEOF
|
||||
else
|
||||
echo "Warning: ${CSS_FILE} not found, skipping ${brand}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Add brand metadata from tokens package if available
|
||||
TOKENS_INDEX="${DESIGN_SYSTEM_DIR}/packages/tokens/dist/index.js"
|
||||
if [ -f "$TOKENS_INDEX" ]; then
|
||||
# Extract brand names/descriptions from the built tokens
|
||||
node -e "
|
||||
const tokens = require('${TOKENS_INDEX}');
|
||||
const brands = Object.values(tokens.brands || {}).map(b => ({
|
||||
id: b.id, name: b.name, description: b.description,
|
||||
supportsModes: ['dark', 'light', 'auto'], defaultMode: 'dark'
|
||||
}));
|
||||
require('fs').writeFileSync('/tmp/theme-staging/theme/themes.json',
|
||||
JSON.stringify({ schema_version: 1, themes: brands }, null, 2));
|
||||
" 2>/dev/null || echo '{"schema_version": 1, "themes": []}' > "${STAGING}/themes.json"
|
||||
else
|
||||
# Fallback: generate registry from what we packaged
|
||||
cat > "${STAGING}/themes.json" << REOF
|
||||
{
|
||||
"schema_version": 1,
|
||||
"themes": [
|
||||
{"id": "dt", "name": "Dangerous Things", "description": "Official DT brand", "supportsModes": ["dark", "light", "auto"], "defaultMode": "dark"},
|
||||
{"id": "classic", "name": "Classic Cyberpunk", "description": "Original dark navy aesthetic", "supportsModes": ["dark", "light", "auto"], "defaultMode": "dark"},
|
||||
{"id": "supra", "name": "VivoKey Supra", "description": "Material Design 3 with VivoKey blue", "supportsModes": ["dark", "light", "auto"], "defaultMode": "dark"}
|
||||
]
|
||||
}
|
||||
REOF
|
||||
fi
|
||||
|
||||
cat > "${STAGING}/COMPONENT_VERSION" << EOF
|
||||
{
|
||||
"component_id": "theme",
|
||||
"version": "${VERSION}",
|
||||
"build_date": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
|
||||
"tokens_package_version": "$(node -p "require('${DESIGN_SYSTEM_DIR}/packages/tokens/package.json').version" 2>/dev/null || echo "unknown")"
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
TARBALL="theme-${VERSION}.tar.gz"
|
||||
cd /tmp/theme-staging
|
||||
tar -czf "${OUTPUT_DIR}/${TARBALL}" theme/
|
||||
echo "✅ Built: ${OUTPUT_DIR}/${TARBALL}"
|
||||
|
||||
rm -rf /tmp/theme-staging
|
||||
124
ci/generate-manifest.py
Executable file
124
ci/generate-manifest.py
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate release-manifest.json from built component tarballs.
|
||||
|
||||
Scans the dist/ directory for component tarballs, reads their
|
||||
COMPONENT_VERSION metadata, computes SHA256 checksums, and writes
|
||||
a release-manifest.json suitable for upload as a GitHub Release asset.
|
||||
|
||||
Usage:
|
||||
python3 ci/generate-manifest.py [dist_dir] [--version VERSION]
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
"""Compute SHA256 checksum of a file."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
while chunk := f.read(65536):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def read_component_version(tarball_path: Path) -> dict:
|
||||
"""Read COMPONENT_VERSION JSON from inside a tarball."""
|
||||
with tarfile.open(tarball_path, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
if member.name.endswith("COMPONENT_VERSION"):
|
||||
f = tf.extractfile(member)
|
||||
if f:
|
||||
return json.loads(f.read().decode())
|
||||
return {}
|
||||
|
||||
|
||||
def main():
|
||||
dist_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("dist")
|
||||
version = None
|
||||
if "--version" in sys.argv:
|
||||
idx = sys.argv.index("--version")
|
||||
version = sys.argv[idx + 1]
|
||||
|
||||
if not dist_dir.exists():
|
||||
print(f"Error: {dist_dir} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
# Read VERSION file if version not specified
|
||||
if not version:
|
||||
version_file = Path("VERSION")
|
||||
version = version_file.read_text().strip() if version_file.exists() else "0.1.0"
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"release_version": version,
|
||||
"components": {},
|
||||
}
|
||||
|
||||
tarballs = sorted(dist_dir.glob("*.tar.gz"))
|
||||
if not tarballs:
|
||||
print(f"Warning: no tarballs found in {dist_dir}")
|
||||
|
||||
for tarball in tarballs:
|
||||
filename = tarball.name
|
||||
checksum = sha256_file(tarball)
|
||||
size = tarball.stat().st_size
|
||||
comp_version = read_component_version(tarball)
|
||||
comp_id = comp_version.get("component_id", "")
|
||||
|
||||
if not comp_id:
|
||||
# Try to infer from filename
|
||||
for known in ("pm3", "frontend", "backend", "theme"):
|
||||
if filename.startswith(known):
|
||||
comp_id = known
|
||||
break
|
||||
|
||||
if not comp_id:
|
||||
print(f" Skipping {filename} (unknown component)")
|
||||
continue
|
||||
|
||||
comp_ver = comp_version.get("version", version)
|
||||
|
||||
# Build asset key (platform-specific for pm3)
|
||||
platform_str = comp_version.get("platform")
|
||||
python_abi = comp_version.get("python_abi")
|
||||
if platform_str and python_abi:
|
||||
asset_key = f"{platform_str}-{python_abi}"
|
||||
else:
|
||||
asset_key = "default"
|
||||
|
||||
asset_info = {
|
||||
"filename": filename,
|
||||
"checksum_sha256": checksum,
|
||||
"size": size,
|
||||
}
|
||||
if comp_version.get("python_version"):
|
||||
asset_info["python_version"] = comp_version["python_version"]
|
||||
if platform_str:
|
||||
asset_info["platform"] = platform_str
|
||||
|
||||
# Initialize component entry if needed
|
||||
if comp_id not in manifest["components"]:
|
||||
manifest["components"][comp_id] = {
|
||||
"version": comp_ver,
|
||||
"assets": {},
|
||||
"changelog": "",
|
||||
}
|
||||
|
||||
manifest["components"][comp_id]["assets"][asset_key] = asset_info
|
||||
print(f" {comp_id}/{asset_key}: {filename} ({size} bytes)")
|
||||
|
||||
# Write manifest
|
||||
manifest_path = dist_dir / "release-manifest.json"
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
print(f"\n✅ Wrote {manifest_path}")
|
||||
print(f" Components: {list(manifest['components'].keys())}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
339
docs/COMPONENT_UPDATE_SYSTEM.md
Normal file
339
docs/COMPONENT_UPDATE_SYSTEM.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Component-Based Update System
|
||||
|
||||
## Context
|
||||
|
||||
The current update system is monolithic — a single `.tar.gz` downloaded from GitHub Releases, extracted to `/opt/dangerous-pi`, followed by `make clean && make` to rebuild PM3 on-device. This is slow, resource-heavy, and fragile on a Pi. The goal is to:
|
||||
|
||||
1. Ship **pre-built PM3 binaries** (cross-compiled in CI) instead of compiling on-device
|
||||
2. Support **per-component updates** (update just the frontend, just the PM3 client, etc.)
|
||||
3. Have the update system **auto-manage requirements** like Python version matching for SWIG `.so` files
|
||||
|
||||
## Component Model
|
||||
|
||||
Four independently-updateable components. 3rd-party themes use the plugin system instead.
|
||||
|
||||
| Component | Artifacts | Install Path | Platform Constraints |
|
||||
|-----------|-----------|-------------|---------------------|
|
||||
| `pm3` | `proxmark3` binary, `libpm3rrg_rdv4.so`, `_pm3.so`, `pm3.py`, `pm3-flash-*`, patches, `bootrom.elf`, `fullimage.elf` | `$HOME/.pm3/proxmark3/` | aarch64-linux, Python version must match |
|
||||
| `frontend` | Remix build output (`build/client/`, `build/server/`), app components, routes | `/opt/dangerous-pi/app/frontend/build/` | None |
|
||||
| `backend` | Python source, `requirements.txt` | `/opt/dangerous-pi/app/backend/` | Python 3.11+ |
|
||||
| `theme` | Generated token CSS from `@dangerousthings/tokens` + `themes.json` registry | `/opt/dangerous-pi/app/frontend/themes/` | None |
|
||||
|
||||
The PM3 client and firmware are always distributed together — they're built from the same source tree with the same patches, and a version mismatch between client and firmware could cause flashing issues.
|
||||
|
||||
### Theme Distribution (two channels)
|
||||
|
||||
**Source of truth:** The `dt-design-system` monorepo at `/home/work/dt-design-system/` (Turborepo + npm workspaces).
|
||||
|
||||
**Monorepo packages:**
|
||||
- `@dangerousthings/tokens` — TypeScript token definitions → generates `dist/css/{dt,classic,supra}.css`
|
||||
- `@dangerousthings/web` — Structural CSS (bevels, glows, elevation, fonts) + imports token CSS
|
||||
- `@dangerousthings/react-native` — RN themed components (DTButton, DTCard, etc.)
|
||||
|
||||
**Currently:** The frontend imports `@dangerousthings/web/dist/index.css` which bundles *everything* (tokens + structural CSS) at Remix build time. This means a theme color change requires a full frontend rebuild.
|
||||
|
||||
**New approach:** Split token CSS (brand colors, typography, shapes) from structural CSS (bevels, glows, elevation):
|
||||
- **Structural CSS** stays bundled with the frontend build (changes rarely, tied to component code)
|
||||
- **Token CSS** (`dt.css`, `classic.css`, `supra.css`) is served dynamically from a `themes/` directory and loaded via `<link>` tags — can be updated without rebuilding the frontend
|
||||
|
||||
**Built-in themes** (dt, classic, supra) are distributed as the `theme` component via the update system. They ship with tested releases and are always available.
|
||||
|
||||
**3rd-party themes** are distributed as plugins via the existing plugin system. A theme plugin provides token CSS + `theme.json` and registers via the `theme_register` plugin hook.
|
||||
|
||||
Both channels write to the same dynamic theme registry, which replaces the current hardcoded `themes[]` array in `ThemeContext.tsx`.
|
||||
|
||||
**Theme package structure** (same format for both channels):
|
||||
```
|
||||
{theme-id}/
|
||||
theme.json # id, name, description, supportsModes, defaultMode, author
|
||||
tokens.css # CSS custom properties (--color-*, --radius-*, --font-*, --shape-*)
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Manifest System (Foundation)
|
||||
|
||||
Add component tracking without changing update behavior yet.
|
||||
|
||||
**Files to modify:**
|
||||
- `app/backend/managers/update_manager.py` — Add dataclasses and manifest I/O
|
||||
|
||||
**New dataclasses:**
|
||||
```python
|
||||
class ComponentId(str, Enum):
|
||||
PM3 = "pm3"
|
||||
FRONTEND = "frontend"
|
||||
BACKEND = "backend"
|
||||
THEME = "theme"
|
||||
|
||||
@dataclass
|
||||
class ComponentInfo:
|
||||
component_id: str
|
||||
version: str
|
||||
installed_at: str
|
||||
checksum: Optional[str] = None
|
||||
install_path: str = ""
|
||||
platform: Optional[str] = None
|
||||
python_version: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class SystemManifest:
|
||||
schema_version: int = 1
|
||||
system_version: str = "0.1.0"
|
||||
components: Dict[str, ComponentInfo] = field(default_factory=dict)
|
||||
last_updated: str = ""
|
||||
```
|
||||
|
||||
**New file:** `/opt/dangerous-pi/component-manifest.json` — replaces the single `VERSION` file.
|
||||
|
||||
**Migration:** On first startup with no manifest, `_migrate_from_legacy()` reads the existing `VERSION` file and PM3 `VERSION.txt` to populate a manifest with current component versions.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: CI/CD Build Pipeline
|
||||
|
||||
Cross-compile PM3 and package all components in GitHub Actions.
|
||||
|
||||
**New files:**
|
||||
- `.github/workflows/build-release.yml` — Main workflow, triggered on tag push (`v*`)
|
||||
- `ci/build-pm3.sh` — Runs in aarch64 Docker container (QEMU), reproduces the build from `pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh`:
|
||||
- Clone RfidResearchGroup/proxmark3
|
||||
- Apply `led-pwm-control.patch` + `hf-booster-detection.patch`
|
||||
- Apply DangerousPi branding
|
||||
- Set `PLATFORM=PM3GENERIC`, `LED_ORDER=PM3EASY`
|
||||
- Build firmware (`bootrom.elf`, `fullimage.elf`) + client + SWIG bindings in one pass
|
||||
- `cmake -DBUILD_PYTHON_LIB=ON`, SWIG bindings
|
||||
- Matrix build for Python 3.11 + 3.12
|
||||
- `ci/build-frontend.sh` — `npm ci && npm run build`, package `build/`
|
||||
- `ci/build-backend.sh` — Package `app/backend/` + `requirements.txt`
|
||||
- `ci/build-theme.sh` — Builds `@dangerousthings/tokens` in the `dt-design-system` monorepo, packages the generated `dist/css/{dt,classic,supra}.css` + a `themes.json` registry
|
||||
- `ci/generate-manifest.py` — Generates `release-manifest.json` with checksums
|
||||
|
||||
**Release asset naming:**
|
||||
```
|
||||
release-manifest.json
|
||||
pm3-{ver}-aarch64-linux-cp312.tar.gz
|
||||
pm3-{ver}-aarch64-linux-cp311.tar.gz
|
||||
frontend-{ver}.tar.gz
|
||||
backend-{ver}.tar.gz
|
||||
theme-{ver}.tar.gz
|
||||
```
|
||||
|
||||
**`release-manifest.json` structure:**
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"release_version": "0.2.0",
|
||||
"components": {
|
||||
"pm3": {
|
||||
"version": "4.19544-dp1",
|
||||
"assets": {
|
||||
"aarch64-linux-cp312": {
|
||||
"filename": "pm3-4.19544-dp1-aarch64-linux-cp312.tar.gz",
|
||||
"checksum_sha256": "...",
|
||||
"size": 12345678,
|
||||
"python_version": "3.12"
|
||||
}
|
||||
},
|
||||
"changelog": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Component-Aware Update Logic
|
||||
|
||||
Refactor `UpdateManager` to download/install individual components.
|
||||
|
||||
**Files to modify:**
|
||||
- `app/backend/managers/update_manager.py`
|
||||
|
||||
**Key changes:**
|
||||
1. `_fetch_latest_release()` → fetch and parse `release-manifest.json` from GitHub release assets (fall back to legacy `.tar.gz` scanning for old releases)
|
||||
2. `_select_pm3_asset()` → auto-detect Pi's Python version (`sys.version_info`) and architecture (`platform.machine()`) to pick the correct pm3 variant
|
||||
3. `check_for_updates()` → return per-component availability with compatibility info
|
||||
4. New `download_component(component_id)` → download a single component tarball
|
||||
5. New `install_component(component_id)` → component-specific install logic:
|
||||
- `pm3`: copy client + firmware to `$HOME/.pm3/proxmark3/`, create `_pm3.so` symlink, verify arch
|
||||
- `frontend`: replace `build/` directory
|
||||
- `backend`: replace source, run `pip install -r requirements.txt` in venv
|
||||
- `theme`: extract to `/opt/dangerous-pi/app/frontend/themes/`, update theme registry
|
||||
6. **Delete `_rebuild_pm3_client()`** — no more on-device compilation
|
||||
7. New `_backup_component()` / `rollback_component()` → per-component backups at `/opt/dangerous-pi/backups/{component_id}/`
|
||||
|
||||
**New status:** `ROLLING_BACK` added to `UpdateStatus` enum.
|
||||
|
||||
**New progress tracking:**
|
||||
```python
|
||||
@dataclass
|
||||
class ComponentProgress:
|
||||
component_id: str
|
||||
status: UpdateStatus
|
||||
download_progress: float = 0.0
|
||||
error_message: Optional[str] = None
|
||||
```
|
||||
|
||||
Added as `component_progress: Dict[str, ComponentProgress]` to `UpdateProgress`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Unified Plugin Update Checking
|
||||
|
||||
Integrate plugin update checks into the same update flow so "Check for Updates" covers everything.
|
||||
|
||||
**Files to modify:**
|
||||
- `app/backend/managers/update_manager.py` — Add plugin update checking alongside component checks
|
||||
- `app/backend/managers/plugin_manager.py` — Add `check_plugin_updates()` method that queries each installed plugin's GitHub repo for newer releases
|
||||
|
||||
**How it works:**
|
||||
1. `check_for_updates()` calls both:
|
||||
- Core component check (from `release-manifest.json` on the main repo)
|
||||
- Plugin update check (queries each plugin's source repo for newer tags)
|
||||
2. Plugin update info is returned alongside component updates in the same response
|
||||
3. Plugin download/install still uses the existing `plugin_manager.py` install logic — the update manager just orchestrates the version check
|
||||
4. Plugin versions are tracked in `component-manifest.json` under a `plugins` key (separate from `components`)
|
||||
|
||||
**Manifest extension:**
|
||||
```json
|
||||
{
|
||||
"components": { ... },
|
||||
"plugins": {
|
||||
"hello_world": {
|
||||
"version": "1.0.0",
|
||||
"source_repo": "dangerous-tacos/dp-plugin-hello-world",
|
||||
"installed_at": "2026-03-03T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Update check response includes plugins:**
|
||||
```python
|
||||
class PluginUpdateInfo(BaseModel):
|
||||
plugin_id: str
|
||||
current_version: str
|
||||
available_version: str
|
||||
changelog: str
|
||||
download_size: Optional[int]
|
||||
```
|
||||
|
||||
The `/api/updates/check` response adds `plugins: List[PluginUpdateInfo]` alongside `components`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: API & Service Layer (includes plugin update endpoints)
|
||||
|
||||
**Files to modify:**
|
||||
- `app/backend/services/update_service.py` — Add `download_component()`, `install_component()`, `rollback_component()`, `get_installed_components()`
|
||||
- `app/backend/api/updates.py` — New endpoints + extended response models
|
||||
|
||||
**New endpoints:**
|
||||
```
|
||||
GET /api/updates/components — installed component manifest
|
||||
POST /api/updates/components/{id}/download — download one component
|
||||
POST /api/updates/components/{id}/install — install one component
|
||||
POST /api/updates/components/{id}/rollback — rollback one component
|
||||
```
|
||||
|
||||
**Modified endpoints (backward-compatible):**
|
||||
- `GET /check` — adds `components: List[ComponentUpdateInfo]` to response
|
||||
- `POST /download` — accepts optional `{"components": ["frontend"]}` body; no body = all
|
||||
- `POST /install` — same optional body pattern
|
||||
- `GET /progress` — adds `active_component` and `components` list to response
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Dynamic Theme Registry
|
||||
|
||||
Decouple token CSS from the frontend build so themes can be updated independently.
|
||||
|
||||
**Current flow (bundled):**
|
||||
`@dangerousthings/web/dist/index.css` → imports token CSS + structural CSS → bundled by Vite into Remix build
|
||||
|
||||
**New flow (split):**
|
||||
- Structural CSS (`base.css`, `bevels.css`, `glows.css`, `elevation.css`, fonts) stays in the Remix build via `@dangerousthings/web`
|
||||
- Token CSS (`dt.css`, `classic.css`, `supra.css`) served from `/themes/{brand}/tokens.css` and loaded dynamically
|
||||
|
||||
**Files to modify:**
|
||||
- `app/frontend/app/lib/ThemeContext.tsx` — Replace hardcoded `themes[]` array with dynamic loading from `GET /api/themes`; inject `<link>` tag for active brand's token CSS
|
||||
- `app/frontend/app/root.tsx` — Remove token CSS from the static import of `@dangerousthings/web/dist/index.css`; keep only structural CSS. Add dynamic `<link>` for token CSS.
|
||||
- `app/backend/api/system.py` — Add `GET /api/themes` endpoint that reads available themes from disk
|
||||
- `app/backend/main.py` — Mount `/themes/` as a static file directory
|
||||
|
||||
**Files to create:**
|
||||
- `app/frontend/themes/` — Directory for installed theme packages (in dev)
|
||||
- `/opt/dangerous-pi/app/frontend/themes/` — Same, in production
|
||||
- Each theme: `themes/{theme-id}/theme.json` + `tokens.css`
|
||||
|
||||
**How it works:**
|
||||
1. Backend scans `themes/` directory + plugin-registered themes → serves merged list at `GET /api/themes`
|
||||
2. `ThemeContext` fetches available themes on mount (with hardcoded fallback for SSR/offline)
|
||||
3. On brand switch, `ThemeContext` updates `<link href="/themes/{brand}/tokens.css">` in the document head
|
||||
4. Plugin themes register via `theme_register` hook, providing their CSS path + `theme.json`
|
||||
|
||||
**Migration from `@dangerousthings/web`:**
|
||||
- `@dangerousthings/web` needs a new export that excludes token CSS: `@dangerousthings/web/dist/structural.css` (or the `index.css` is refactored to not import token CSS)
|
||||
- The CI `build-theme.sh` step copies `@dangerousthings/tokens/dist/css/{dt,classic,supra}.css` into the theme tarball as `{brand}/tokens.css`
|
||||
|
||||
**Theme plugin hook** (added to plugin_manager.py):
|
||||
```python
|
||||
# Plugin calls this in on_enable():
|
||||
self.register_hook("theme_register", self._register_theme)
|
||||
|
||||
def _register_theme(self):
|
||||
return {
|
||||
"id": "my-theme",
|
||||
"name": "My Custom Theme",
|
||||
"css_path": self.plugin_dir / "tokens.css",
|
||||
"theme_json": self.plugin_dir / "theme.json"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Frontend UI
|
||||
|
||||
**File to modify:**
|
||||
- `app/frontend/app/routes/updates.tsx`
|
||||
|
||||
**Layout:**
|
||||
1. **System overview card** — system version, last check, "Check for Updates" + "Update All" buttons
|
||||
2. **Component cards** — one per component showing:
|
||||
- Name, installed version, available version
|
||||
- Compatibility badge (for pm3 Python version match)
|
||||
- Collapsible per-component changelog
|
||||
- Individual Download / Install / Rollback buttons
|
||||
- Per-component progress bar
|
||||
3. **Plugin update cards** — shown for any installed plugins with updates available, using the same card layout. Download/install delegates to the existing plugin manager.
|
||||
4. **Status states per card:** up-to-date (muted), available (accent), downloading (progress bar), installing (spinner), complete (success), failed (danger + rollback button), incompatible (warning + reason)
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Testing & Polish
|
||||
|
||||
- Test legacy migration (no manifest → auto-generated manifest)
|
||||
- Test partial update (update only frontend, leave PM3 alone)
|
||||
- Test Python version mismatch (pm3-client shows incompatible)
|
||||
- Test rollback per component
|
||||
- Test disk space check before download
|
||||
- Test backward compat with a legacy monolithic release (no `release-manifest.json`)
|
||||
- Test theme update (update `theme` component without touching frontend)
|
||||
- Test 3rd-party theme plugin (install, register, appear in theme selector, uninstall)
|
||||
- Test plugin update check (installed plugin with newer release shows in update check response)
|
||||
- Test plugin update install (update delegates to plugin_manager install logic)
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Manifest migration**: Start backend with no `component-manifest.json` — verify it auto-creates from `VERSION` files
|
||||
2. **CI pipeline**: Push a test tag — verify all component tarballs + `release-manifest.json` are produced
|
||||
3. **Component check**: `GET /api/updates/check` — verify per-component version comparison
|
||||
4. **Selective update**: Download + install only `frontend` — verify only frontend files change
|
||||
5. **PM3 binary**: Install `pm3-client` — verify binary runs, `_pm3.so` symlink works, no compilation triggered
|
||||
6. **Rollback**: Install a component, then `POST /rollback` — verify files restored and manifest updated
|
||||
7. **Python version matching**: On a Pi with Python 3.12, verify `cp312` asset is selected; `cp311` shown as incompatible
|
||||
8. **Theme update**: Update `theme` component — verify new CSS applied without frontend rebuild
|
||||
9. **Theme plugin**: Install a theme plugin — verify it appears in theme selector alongside built-in themes
|
||||
10. **Plugin updates**: Install a plugin, publish a newer release to its repo — verify `GET /api/updates/check` includes it in the response and install works via existing plugin manager
|
||||
330
docs/ON_DEVICE_TESTING.md
Normal file
330
docs/ON_DEVICE_TESTING.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# On-Device Testing & Iteration Guide
|
||||
|
||||
How to build, deploy, and test Dangerous Pi components on a live Pi Zero 2 W from a development laptop.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Pi connectivity**: WiFi (`dangerous-pi.local` / IP) or Ethernet
|
||||
- **Serial console**: USB-to-serial adapter at `/dev/ttyUSB0` (115200 baud)
|
||||
- **Docker**: With QEMU binfmt support for `linux/arm64`
|
||||
- **ARM toolchain**: `arm-none-eabi-gcc` (firmware only)
|
||||
- **Python venv**: `.venv/` with `httpx`, `pyserial`, `websockets`
|
||||
|
||||
## Connectivity
|
||||
|
||||
| Method | Address | Use |
|
||||
|--------|---------|-----|
|
||||
| HTTP API | `http://dangerous-pi.local:8000/api/` | Backend testing |
|
||||
| HTTP UI | `http://dangerous-pi.local/` | Frontend (via nginx) |
|
||||
| HTTPS | `https://dangerous-pi.local/` | If HTTPS enabled |
|
||||
| Serial | `/dev/ttyUSB0` @ 115200 | System commands, logs, deployment |
|
||||
| Bluetooth | Local adapter | BLE scan testing |
|
||||
|
||||
---
|
||||
|
||||
## 1. Cross-Compiling Proxmark3
|
||||
|
||||
The PM3 has two components that must match versions: **firmware** (ARM7TDMI, runs on PM3 hardware) and **client** (aarch64, runs on Pi).
|
||||
|
||||
### Source Setup
|
||||
|
||||
```bash
|
||||
cd /home/work/dangerous-pi/.pm3-test/proxmark3
|
||||
|
||||
# Keep in sync with upstream
|
||||
git fetch origin
|
||||
git pull origin master
|
||||
|
||||
# Configure platform
|
||||
cat > Makefile.platform << 'EOF'
|
||||
PLATFORM=PM3GENERIC
|
||||
LED_ORDER=PM3EASY
|
||||
EOF
|
||||
```
|
||||
|
||||
### Build Firmware (Native, Fast)
|
||||
|
||||
Firmware targets ARM7TDMI (the PM3's own chip), so `arm-none-eabi-gcc` builds it natively on x86:
|
||||
|
||||
```bash
|
||||
cd /home/work/dangerous-pi/.pm3-test/proxmark3
|
||||
SKIPQT=1 make -j$(nproc) bootrom fullimage
|
||||
```
|
||||
|
||||
Output:
|
||||
- `bootrom/obj/bootrom.elf` (~10KB)
|
||||
- `armsrc/obj/fullimage.elf` (~370KB)
|
||||
|
||||
Build time: ~1 minute on x86.
|
||||
|
||||
### Build Client (Docker aarch64, Slow)
|
||||
|
||||
The client runs on the Pi (aarch64), so we cross-compile using Docker with QEMU emulation:
|
||||
|
||||
```bash
|
||||
# Build the Docker image (one-time)
|
||||
cd /home/work/dangerous-pi/.pm3-test
|
||||
docker build --platform linux/arm64 -t pm3-client-builder -f Dockerfile.pm3-client .
|
||||
|
||||
# IMPORTANT: Clean x86 build artifacts first
|
||||
cd proxmark3
|
||||
make client/clean
|
||||
|
||||
# Build the aarch64 client
|
||||
docker run --rm --platform linux/arm64 \
|
||||
-v "$(pwd):/build/proxmark3" \
|
||||
pm3-client-builder \
|
||||
bash -c "cd /build/proxmark3 && SKIPQT=1 make client"
|
||||
```
|
||||
|
||||
Output:
|
||||
- `client/proxmark3` (aarch64 ELF, ~5.7MB)
|
||||
|
||||
Build time: ~25 minutes (QEMU emulation is slow).
|
||||
|
||||
Verify architecture:
|
||||
```bash
|
||||
file client/proxmark3
|
||||
# Should show: ELF 64-bit LSB pie executable, ARM aarch64
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Located at `.pm3-test/Dockerfile.pm3-client`:
|
||||
|
||||
```dockerfile
|
||||
FROM --platform=linux/arm64 debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential pkg-config libreadline-dev libbz2-dev \
|
||||
liblz4-dev libgd-dev libjansson-dev libbluetooth-dev \
|
||||
python3-dev swig cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build/proxmark3
|
||||
```
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- **Stale x86 objects**: If you built on the host first, `.o` files in the volume mount will be x86_64. Docker can't link them. Always `make client/clean` before the Docker build.
|
||||
- **Merge conflicts with LED patch**: When pulling upstream, our `cmdhw.c` and `pm3_cmd.h` changes may conflict. Resolve by keeping our `CMD_LED_CONTROL` define and `led` command while taking upstream's new code.
|
||||
|
||||
---
|
||||
|
||||
## 2. Deploying to Pi
|
||||
|
||||
### Transfer Files (HTTP Server Method)
|
||||
|
||||
From the laptop, serve the built files:
|
||||
|
||||
```bash
|
||||
cd /home/work/dangerous-pi/.pm3-test/proxmark3
|
||||
python3 -m http.server 9999 --bind $(hostname -I | awk '{print $1}')
|
||||
```
|
||||
|
||||
From the Pi (via serial console or SSH):
|
||||
|
||||
```bash
|
||||
LAPTOP_IP=10.0.0.202 # adjust to your laptop's IP
|
||||
mkdir -p ~/pm3-update
|
||||
|
||||
# Download client and firmware
|
||||
curl -o ~/pm3-update/proxmark3 http://$LAPTOP_IP:9999/client/proxmark3
|
||||
curl -o ~/pm3-update/fullimage.elf http://$LAPTOP_IP:9999/armsrc/obj/fullimage.elf
|
||||
curl -o ~/pm3-update/bootrom.elf http://$LAPTOP_IP:9999/bootrom/obj/bootrom.elf
|
||||
|
||||
chmod +x ~/pm3-update/proxmark3
|
||||
```
|
||||
|
||||
### Deploy Binaries
|
||||
|
||||
```bash
|
||||
# Stop the service first
|
||||
sudo systemctl stop dangerous-pi
|
||||
|
||||
# Backup current binaries
|
||||
cp ~/.pm3/proxmark3/client/proxmark3 ~/.pm3/proxmark3/client/proxmark3.bak
|
||||
cp ~/.pm3/proxmark3/firmware/fullimage.elf ~/.pm3/proxmark3/firmware/fullimage.elf.bak
|
||||
|
||||
# Deploy new binaries
|
||||
cp ~/pm3-update/proxmark3 ~/.pm3/proxmark3/client/
|
||||
cp ~/pm3-update/fullimage.elf ~/.pm3/proxmark3/firmware/
|
||||
cp ~/pm3-update/bootrom.elf ~/.pm3/proxmark3/firmware/
|
||||
```
|
||||
|
||||
### Flash Firmware
|
||||
|
||||
```bash
|
||||
cd ~/.pm3/proxmark3
|
||||
./pm3-flash-all
|
||||
```
|
||||
|
||||
After flashing, the PM3 resets. If it doesn't re-enumerate on USB (`/dev/ttyACM0`), physically unplug and replug the USB cable.
|
||||
|
||||
### Restart Service
|
||||
|
||||
```bash
|
||||
sudo systemctl start dangerous-pi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploying Backend Changes
|
||||
|
||||
For Python backend changes (e.g., `ble_manager.py`, `system.py`):
|
||||
|
||||
### Quick Inline Fix (via serial sed)
|
||||
|
||||
For small changes, use `sed -i` over serial:
|
||||
|
||||
```bash
|
||||
# Example: fix a string match in system.py
|
||||
sudo sed -i 's/"subjectAltName"/"Subject Alternative Name"/' \
|
||||
/opt/dangerous-pi/app/backend/api/system.py
|
||||
sudo systemctl restart dangerous-pi
|
||||
```
|
||||
|
||||
### Full File Transfer (HTTP method)
|
||||
|
||||
```bash
|
||||
# On laptop - serve the file
|
||||
cd /home/work/dangerous-pi
|
||||
python3 -m http.server 9999 --bind $(hostname -I | awk '{print $1}')
|
||||
|
||||
# On Pi - download and deploy
|
||||
curl -o /tmp/ble_manager.py http://$LAPTOP_IP:9999/app/backend/managers/ble_manager.py
|
||||
sudo cp /tmp/ble_manager.py /opt/dangerous-pi/app/backend/managers/ble_manager.py
|
||||
sudo systemctl restart dangerous-pi
|
||||
```
|
||||
|
||||
### Deploying nginx Config
|
||||
|
||||
```bash
|
||||
# On Pi
|
||||
curl -o /tmp/dangerous-pi-https.conf http://$LAPTOP_IP:9999/nginx/dangerous-pi-https.conf
|
||||
sudo cp /tmp/dangerous-pi-https.conf /opt/dangerous-pi/nginx/
|
||||
sudo /opt/dangerous-pi/scripts/configure-nginx.sh
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Running the Hardware Test Suite
|
||||
|
||||
The test suite at `test_hardware_2026-03-03.py` validates everything against the live Pi.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
pip install httpx pyserial websockets
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
python test_hardware_2026-03-03.py
|
||||
```
|
||||
|
||||
### Run Specific Phase
|
||||
|
||||
```bash
|
||||
python test_hardware_2026-03-03.py --phase 1 # Regression (Jan 23 issues)
|
||||
python test_hardware_2026-03-03.py --phase 2 # Core health endpoints
|
||||
python test_hardware_2026-03-03.py --phase 3 # PM3 hardware
|
||||
python test_hardware_2026-03-03.py --phase 4 # WiFi manager
|
||||
python test_hardware_2026-03-03.py --phase 5 # HTTPS/SSL
|
||||
python test_hardware_2026-03-03.py --phase 6 # Auth & WebSocket
|
||||
python test_hardware_2026-03-03.py --phase 7 # Plugins
|
||||
python test_hardware_2026-03-03.py --phase 8 # BLE & UPS
|
||||
python test_hardware_2026-03-03.py --phase 9 # Frontend & performance
|
||||
```
|
||||
|
||||
### Safe Mode (Skip Destructive Tests)
|
||||
|
||||
```bash
|
||||
python test_hardware_2026-03-03.py --safe-only
|
||||
```
|
||||
|
||||
Results are written to `TEST_RESULTS_2026-03-03_vN.md`.
|
||||
|
||||
### Test Phases Overview
|
||||
|
||||
| Phase | Tests | What It Covers |
|
||||
|-------|-------|----------------|
|
||||
| 1 | 4 | Regression of Jan 2026 issues (WiFi, UPS, BLE, Pydantic) |
|
||||
| 2 | 9 | Health, readiness, system info, config, response times |
|
||||
| 3 | 14 | PM3 device discovery, sessions, command execution |
|
||||
| 4 | 6 | WiFi status, scanning, saved networks |
|
||||
| 5 | 8 | SSL cert info, HTTPS access, cert scripts |
|
||||
| 6 | 10 | Auth enable/disable, token flow, WebSocket |
|
||||
| 7 | 10 | Plugin discover, load, enable, hooks, widgets |
|
||||
| 8 | 10 | BLE status/advertising, UPS battery/thresholds |
|
||||
| 9 | 8 | Frontend pages, response times, memory, CPU temp |
|
||||
|
||||
---
|
||||
|
||||
## 5. Useful Serial Console Commands
|
||||
|
||||
```bash
|
||||
# Service management
|
||||
sudo systemctl status dangerous-pi
|
||||
sudo systemctl restart dangerous-pi
|
||||
journalctl -u dangerous-pi -f # Follow logs
|
||||
|
||||
# Check PM3
|
||||
ls /dev/ttyACM* # USB device present?
|
||||
dmesg | tail -20 # USB errors?
|
||||
~/.pm3/proxmark3/client/proxmark3 -p /dev/ttyACM0 -c "hw version"
|
||||
|
||||
# Check networking
|
||||
ip addr show wlan0
|
||||
nmcli connection show --active
|
||||
curl -s http://localhost:8000/api/health
|
||||
|
||||
# Check SSL
|
||||
ls -la /opt/dangerous-pi/ssl/
|
||||
openssl x509 -in /opt/dangerous-pi/ssl/dangerous-pi.crt -text -noout | head -20
|
||||
|
||||
# Check BLE
|
||||
journalctl -u dangerous-pi --no-pager | grep -i "ble\|bluetooth"
|
||||
|
||||
# Check UPS
|
||||
journalctl -u dangerous-pi --no-pager | grep -i "ups\|pisugar\|battery"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Known Issues & Workarounds
|
||||
|
||||
### PM3 USB Enumeration After Flash
|
||||
|
||||
After `pm3-flash-all`, the PM3 sometimes fails to re-enumerate on USB (dmesg shows `error -71`). Fix: physically unplug and replug the USB cable.
|
||||
|
||||
### PM3 Client Exit Code 1
|
||||
|
||||
When client and firmware versions don't match, `pm3` exits with code 1 (despite producing valid output). The `SubprocessPM3Worker` treats this as failure, putting output in the `error` field instead of `output`. Fix: ensure client and firmware are built from the same commit.
|
||||
|
||||
### SWIG Library on Pi Zero
|
||||
|
||||
Building the SWIG Python bindings (`cmake -DBUILD_PYTHON_LIB=ON`) can OOM on Pi Zero 2 W (512MB RAM). The pi-gen chroot build handles this with swap, but on-device rebuilds may fail. The `SubprocessPM3Worker` (used by default) doesn't need SWIG — it shells out to the `pm3` CLI.
|
||||
|
||||
### SSL Directory Permissions
|
||||
|
||||
`/opt/dangerous-pi/ssl/` must be readable by the `dt` user (backend runs as `dt`):
|
||||
```bash
|
||||
sudo chmod 755 /opt/dangerous-pi/ssl/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Pi-gen vs Cross-Compilation
|
||||
|
||||
| Aspect | pi-gen (image build) | Cross-compile (dev iteration) |
|
||||
|--------|---------------------|-------------------------------|
|
||||
| When | Building release images | Day-to-day development |
|
||||
| Where | Docker chroot (aarch64) | Laptop + Docker for client |
|
||||
| Speed | Full build: hours | Firmware: 1min, Client: 25min |
|
||||
| SWIG | Built in chroot | Not needed (SubprocessPM3Worker) |
|
||||
| LED Patch | Applied via `led-pwm-control.patch` | Maintained in git (merge conflicts) |
|
||||
| Deploy | Baked into `.img` | HTTP transfer + manual copy |
|
||||
|
||||
The pi-gen pipeline (`stagePM3/01-proxmark3/00-run-chroot.sh`) builds everything inside an aarch64 chroot — firmware, client, and SWIG bindings. For development iteration, cross-compilation from the laptop is faster and doesn't require a full image rebuild.
|
||||
@@ -13,4 +13,4 @@ FIRST_USER_PASS="proxmark3"
|
||||
ENABLE_SSH=1
|
||||
WPA_COUNTRY="US"
|
||||
DISABLE_FIRST_BOOT_USER_RENAME=1
|
||||
STAGE_LIST="stage0 stage1 stage2 stagePM3 stageDangerousPi"
|
||||
STAGE_LIST="stage0 stage1 stage2 stageDangerousPi"
|
||||
|
||||
17
pi-gen/stageDangerousPi/00-apt-setup/00-packages
Normal file
17
pi-gen/stageDangerousPi/00-apt-setup/00-packages
Normal file
@@ -0,0 +1,17 @@
|
||||
# Consolidated package list for stageDangerousPi
|
||||
# All packages needed across substages, installed in one apt-get call
|
||||
# to avoid redundant apt-get update / Reading package lists overhead.
|
||||
|
||||
# 01-Wireless-AP: WiFi access point + reverse proxy
|
||||
hostapd
|
||||
dnsmasq
|
||||
nftables
|
||||
nginx
|
||||
|
||||
# 03-dangerous-pi: backend/frontend runtime + utilities
|
||||
nodejs
|
||||
npm
|
||||
lrzsz
|
||||
|
||||
# 04-pisugar: I2C tools for UPS battery monitoring
|
||||
i2c-tools
|
||||
@@ -7,13 +7,7 @@
|
||||
|
||||
echo "=== Setting up WiFi Access Point ==="
|
||||
|
||||
# Install required packages
|
||||
echo "Installing hostapd, dnsmasq, nginx..."
|
||||
apt-get install -y \
|
||||
hostapd \
|
||||
dnsmasq \
|
||||
nftables \
|
||||
nginx
|
||||
# Packages (hostapd, dnsmasq, nftables, nginx) installed by 00-apt-setup stage
|
||||
|
||||
# Note: We no longer use static config file for wlan0 management.
|
||||
# The wifi_manager now uses 'nmcli device set wlan0 managed yes/no' dynamically.
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
|
||||
echo "=== Starting Proxmark3 build with Python bindings ==="
|
||||
|
||||
# Check if build can be skipped (manifest unchanged from previous build)
|
||||
if [ -f /tmp/SKIP_PM3_BUILD ]; then
|
||||
echo "=== PM3 build manifest unchanged — checking cached installation ==="
|
||||
DEFAULT_USER=$(awk -F: '$3 >= 1000 && $3 < 65534 {print $1; exit}' /etc/passwd)
|
||||
DEFAULT_HOME=$(eval echo ~${DEFAULT_USER:-pi})
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/proxmark3" ] && \
|
||||
[ -f "$DEFAULT_HOME/.pm3/proxmark3/firmware/fullimage.elf" ]; then
|
||||
echo "✓ PM3 client binary present"
|
||||
echo "✓ PM3 firmware present"
|
||||
echo "=== Skipping Proxmark3 build (cached) ==="
|
||||
exit 0
|
||||
else
|
||||
echo "✗ PM3 binaries missing despite manifest match — rebuilding"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install build dependencies
|
||||
# apt-get update not needed: metadata inherited from stage0 (same build session)
|
||||
echo "Installing build dependencies..."
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
cmake \
|
||||
python3-dev \
|
||||
@@ -29,12 +45,23 @@ apt-get install -y \
|
||||
libbluetooth-dev \
|
||||
libgd-dev
|
||||
|
||||
# Read pinned version config if available
|
||||
PM3_REPO="https://github.com/RfidResearchGroup/proxmark3"
|
||||
PM3_COMMIT=""
|
||||
if [ -f /tmp/pm3-version.conf ]; then
|
||||
. /tmp/pm3-version.conf
|
||||
fi
|
||||
|
||||
# Clone Proxmark3 repository
|
||||
echo "Cloning Proxmark3 repository..."
|
||||
cd /tmp
|
||||
rm -rf proxmark3 # Clean any existing clone
|
||||
git clone https://github.com/RfidResearchGroup/proxmark3
|
||||
git clone "$PM3_REPO" proxmark3
|
||||
cd proxmark3
|
||||
if [ -n "$PM3_COMMIT" ]; then
|
||||
echo "Checking out pinned commit: $PM3_COMMIT"
|
||||
git checkout "$PM3_COMMIT"
|
||||
fi
|
||||
|
||||
# Apply LED PWM control patch (custom Dangerous Pi feature)
|
||||
# Patch was copied to /tmp by 00-run.sh (pre-chroot script)
|
||||
@@ -368,6 +395,13 @@ else
|
||||
echo "✗ WARNING: Rebuild script not installed"
|
||||
fi
|
||||
|
||||
# Save build manifest for future cache checks
|
||||
if [ -f /tmp/pm3-build-manifest ]; then
|
||||
mkdir -p /opt/dangerous-pi
|
||||
cp /tmp/pm3-build-manifest /opt/dangerous-pi/.pm3-build-manifest
|
||||
echo "✓ Build manifest saved for cache"
|
||||
fi
|
||||
|
||||
# Print summary
|
||||
echo "=== Proxmark3 build complete ==="
|
||||
echo "Installation location: $DEFAULT_HOME/.pm3/proxmark3/"
|
||||
282
pi-gen/stageDangerousPi/02-pm3-install/00-run-chroot.sh
Normal file
282
pi-gen/stageDangerousPi/02-pm3-install/00-run-chroot.sh
Normal file
@@ -0,0 +1,282 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
# Install Proxmark3 from pre-built tarball or compile from source as fallback.
|
||||
#
|
||||
# Pre-built tarball: extracted from /tmp/pm3-prebuilt.tar.gz (staged by 00-run.sh)
|
||||
# Compile fallback: triggered when /tmp/PM3_COMPILE_FROM_SOURCE exists
|
||||
|
||||
echo "=== Proxmark3 Installation ==="
|
||||
|
||||
# --- Check if we need to compile from source (fallback) ---
|
||||
if [ -f /tmp/PM3_COMPILE_FROM_SOURCE ]; then
|
||||
echo "No pre-built tarball available — compiling from source"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -f "${SCRIPT_DIR}/00-run-chroot-compile.sh" ]; then
|
||||
exec "${SCRIPT_DIR}/00-run-chroot-compile.sh"
|
||||
else
|
||||
echo "ERROR: Compile fallback script not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Pre-built tarball installation ---
|
||||
if [ ! -f /tmp/pm3-prebuilt.tar.gz ]; then
|
||||
echo "ERROR: PM3 tarball not found at /tmp/pm3-prebuilt.tar.gz"
|
||||
echo "Set PM3_TARBALL env var or place a tarball in 02-pm3-install/files/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install runtime dependencies only (no build tools needed)
|
||||
echo "Installing runtime dependencies..."
|
||||
apt-get install -y \
|
||||
libreadline8t64 \
|
||||
libusb-1.0-0 \
|
||||
liblz4-1 \
|
||||
libbz2-1.0 \
|
||||
libjansson4 \
|
||||
libgd3t64 \
|
||||
libssl3t64 \
|
||||
libbluetooth3
|
||||
|
||||
# Detect the default user (first UID >= 1000, excluding nobody)
|
||||
DEFAULT_USER=$(awk -F: '$3 >= 1000 && $3 < 65534 {print $1; exit}' /etc/passwd)
|
||||
if [ -z "$DEFAULT_USER" ]; then
|
||||
DEFAULT_USER="pi"
|
||||
fi
|
||||
DEFAULT_HOME=$(eval echo ~$DEFAULT_USER)
|
||||
echo "Installing for user: $DEFAULT_USER (home: $DEFAULT_HOME)"
|
||||
|
||||
# Create installation directory
|
||||
mkdir -p "$DEFAULT_HOME/.pm3/proxmark3"
|
||||
|
||||
# Extract pre-built tarball
|
||||
# Tarball structure: pm3/{client/,firmware/,patches/,scripts/,pm3-flash-*,COMPONENT_VERSION}
|
||||
echo "Extracting PM3 tarball..."
|
||||
tar -xzf /tmp/pm3-prebuilt.tar.gz -C "$DEFAULT_HOME/.pm3/proxmark3" --strip-components=1
|
||||
|
||||
# Ensure _pm3.so symlink exists (critical for Python import)
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/libpm3rrg_rdv4.so" ] && \
|
||||
[ ! -f "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/_pm3.so" ]; then
|
||||
echo "Creating _pm3.so symlink..."
|
||||
cd "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts"
|
||||
ln -sf libpm3rrg_rdv4.so _pm3.so
|
||||
fi
|
||||
|
||||
# Ensure flash utilities are executable
|
||||
chmod +x "$DEFAULT_HOME/.pm3/proxmark3/pm3-flash-"* 2>/dev/null || true
|
||||
|
||||
# Install PM3 rebuild script (for on-device source rebuilds)
|
||||
echo "Installing rebuild script..."
|
||||
mkdir -p "$DEFAULT_HOME/.pm3/proxmark3/scripts"
|
||||
cat > "$DEFAULT_HOME/.pm3/proxmark3/scripts/rebuild-pm3.sh" << 'REBUILD_EOF'
|
||||
#!/bin/bash
|
||||
# Rebuild Proxmark3 client and firmware with LED PWM control patch
|
||||
# Usage: ./rebuild-pm3.sh [--flash]
|
||||
|
||||
set -e
|
||||
|
||||
PM3_HOME="$HOME/.pm3/proxmark3"
|
||||
BUILD_DIR="/tmp/proxmark3-rebuild"
|
||||
PATCH_FILE="$PM3_HOME/patches/led-pwm-control.patch"
|
||||
|
||||
echo "=== Proxmark3 Rebuild Script ==="
|
||||
echo "This will rebuild the PM3 client and firmware with LED PWM control."
|
||||
echo ""
|
||||
|
||||
# Check for ARM toolchain
|
||||
if ! command -v arm-none-eabi-gcc &> /dev/null; then
|
||||
echo "ERROR: ARM toolchain not found. Install with:"
|
||||
echo " sudo apt install gcc-arm-none-eabi"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean previous build
|
||||
rm -rf "$BUILD_DIR"
|
||||
|
||||
# Clone fresh copy
|
||||
echo "Cloning Proxmark3 repository..."
|
||||
git clone --depth 1 https://github.com/RfidResearchGroup/proxmark3 "$BUILD_DIR"
|
||||
cd "$BUILD_DIR"
|
||||
|
||||
# Apply LED PWM control patch
|
||||
if [ -f "$PATCH_FILE" ]; then
|
||||
echo "Applying LED PWM control patch..."
|
||||
patch -p1 < "$PATCH_FILE"
|
||||
echo "Patch applied"
|
||||
else
|
||||
echo "WARNING: LED patch not found at $PATCH_FILE"
|
||||
fi
|
||||
|
||||
# Apply HF booster detection patch
|
||||
if [ -f "$PM3_HOME/patches/hf-booster-detection.patch" ]; then
|
||||
echo "Applying HF booster detection patch..."
|
||||
patch -p1 < "$PM3_HOME/patches/hf-booster-detection.patch"
|
||||
fi
|
||||
|
||||
# Add Dangerous Pi branding to version string
|
||||
echo "Adding Dangerous Pi branding..."
|
||||
sed -i 's/^fullgitinfo="Iceman"$/fullgitinfo="Iceman\/DangerousPi"/' tools/mkversion.sh
|
||||
|
||||
# Configure build
|
||||
echo "Configuring build..."
|
||||
cat > Makefile.platform << EOF
|
||||
PLATFORM=PM3GENERIC
|
||||
LED_ORDER=PM3EASY
|
||||
EOF
|
||||
|
||||
# Build firmware and client
|
||||
echo "Building firmware (this takes a while on Pi Zero)..."
|
||||
make clean
|
||||
make -j$(nproc)
|
||||
|
||||
# Build client with Python bindings
|
||||
echo "Building client..."
|
||||
cd client
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake .. -DBUILD_PYTHON_LIB=ON
|
||||
make -j$(nproc)
|
||||
|
||||
# Build experimental Python library
|
||||
echo "Building Python bindings..."
|
||||
cd ../experimental_lib
|
||||
./00make_swig.sh
|
||||
./01make_lib.sh
|
||||
cd "$BUILD_DIR"
|
||||
|
||||
# Backup current installation
|
||||
echo "Backing up current installation..."
|
||||
if [ -f "$PM3_HOME/client/proxmark3" ]; then
|
||||
cp "$PM3_HOME/client/proxmark3" "$PM3_HOME/client/proxmark3.bak"
|
||||
fi
|
||||
if [ -f "$PM3_HOME/firmware/fullimage.elf" ]; then
|
||||
cp "$PM3_HOME/firmware/fullimage.elf" "$PM3_HOME/firmware/fullimage.elf.bak"
|
||||
fi
|
||||
|
||||
# Install new binaries
|
||||
echo "Installing new client..."
|
||||
cp "$BUILD_DIR/client/build/proxmark3" "$PM3_HOME/client/"
|
||||
cp "$BUILD_DIR/client/experimental_lib/build/libpm3rrg_rdv4.so" "$PM3_HOME/client/pyscripts/"
|
||||
cp "$BUILD_DIR/client/pyscripts/*.py" "$PM3_HOME/client/pyscripts/"
|
||||
|
||||
echo "Installing new firmware..."
|
||||
cp "$BUILD_DIR/bootrom/obj/bootrom.elf" "$PM3_HOME/firmware/"
|
||||
cp "$BUILD_DIR/armsrc/obj/fullimage.elf" "$PM3_HOME/firmware/"
|
||||
|
||||
# Update flash utilities
|
||||
echo "Updating flash utilities..."
|
||||
cp "$BUILD_DIR/pm3-flash-all" "$PM3_HOME/"
|
||||
cp "$BUILD_DIR/pm3-flash-bootrom" "$PM3_HOME/"
|
||||
cp "$BUILD_DIR/pm3-flash-fullimage" "$PM3_HOME/"
|
||||
chmod +x "$PM3_HOME/pm3-flash-"*
|
||||
|
||||
# Update version
|
||||
echo "Updating version info..."
|
||||
cd "$BUILD_DIR"
|
||||
git describe --always --tags > "$PM3_HOME/VERSION.txt"
|
||||
echo "Build date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> "$PM3_HOME/VERSION.txt"
|
||||
echo "Built with LED PWM control patch" >> "$PM3_HOME/VERSION.txt"
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo "New client: $PM3_HOME/client/proxmark3"
|
||||
echo "New firmware: $PM3_HOME/firmware/"
|
||||
echo ""
|
||||
|
||||
# Flash if requested
|
||||
if [ "$1" == "--flash" ]; then
|
||||
echo "Flashing firmware to connected device..."
|
||||
cd "$PM3_HOME"
|
||||
./pm3-flash-all
|
||||
echo "Flash complete"
|
||||
else
|
||||
echo "To flash the new firmware, run:"
|
||||
echo " cd $PM3_HOME && ./pm3-flash-all"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$BUILD_DIR"
|
||||
echo "Cleanup complete"
|
||||
REBUILD_EOF
|
||||
chmod +x "$DEFAULT_HOME/.pm3/proxmark3/scripts/rebuild-pm3.sh"
|
||||
|
||||
# Set proper ownership
|
||||
echo "Setting ownership..."
|
||||
chown -R "$DEFAULT_USER:$DEFAULT_USER" "$DEFAULT_HOME/.pm3"
|
||||
|
||||
# Add Python path to environment
|
||||
echo "Configuring Python path..."
|
||||
touch "$DEFAULT_HOME/.bashrc"
|
||||
if ! grep -q "PYTHONPATH.*\.pm3" "$DEFAULT_HOME/.bashrc"; then
|
||||
echo 'export PYTHONPATH=$HOME/.pm3/proxmark3/client/pyscripts:$PYTHONPATH' >> "$DEFAULT_HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# --- Verification ---
|
||||
echo "Verifying installation..."
|
||||
|
||||
ERRORS=0
|
||||
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/proxmark3" ]; then
|
||||
echo "Client executable installed"
|
||||
else
|
||||
echo "ERROR: Client executable not found"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Verify library architecture
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/libpm3rrg_rdv4.so" ]; then
|
||||
LIB_ARCH=$(file "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/libpm3rrg_rdv4.so" | grep -o 'ARM aarch64\|x86-64\|ARM,')
|
||||
if [ "$LIB_ARCH" = "ARM aarch64" ]; then
|
||||
echo "Library architecture correct: $LIB_ARCH"
|
||||
else
|
||||
echo "ERROR: Library architecture mismatch! Expected ARM aarch64, got: $LIB_ARCH"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/pm3.py" ]; then
|
||||
echo "Python module installed"
|
||||
else
|
||||
echo "ERROR: Python module not found"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/_pm3.so" ]; then
|
||||
echo "Python bindings library installed (_pm3.so)"
|
||||
else
|
||||
echo "ERROR: Python bindings library not found (_pm3.so)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/firmware/fullimage.elf" ]; then
|
||||
echo "Firmware files installed"
|
||||
else
|
||||
echo "ERROR: Firmware files not found"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/pm3-flash-all" ]; then
|
||||
echo "Flash utilities installed"
|
||||
else
|
||||
echo "WARNING: Flash utilities not found"
|
||||
fi
|
||||
|
||||
if [ $ERRORS -gt 0 ]; then
|
||||
echo "ERROR: $ERRORS verification checks failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up tarball
|
||||
rm -f /tmp/pm3-prebuilt.tar.gz
|
||||
|
||||
# Print summary
|
||||
echo "=== Proxmark3 installation complete ==="
|
||||
echo "Installation: $DEFAULT_HOME/.pm3/proxmark3/"
|
||||
echo "Client: $DEFAULT_HOME/.pm3/proxmark3/client/proxmark3"
|
||||
echo "Flash utils: $DEFAULT_HOME/.pm3/proxmark3/pm3-flash-all"
|
||||
echo "Python: $DEFAULT_HOME/.pm3/proxmark3/client/pyscripts/pm3.py"
|
||||
echo "Firmware: $DEFAULT_HOME/.pm3/proxmark3/firmware/"
|
||||
if [ -f "$DEFAULT_HOME/.pm3/proxmark3/COMPONENT_VERSION" ]; then
|
||||
echo "Version info:"
|
||||
cat "$DEFAULT_HOME/.pm3/proxmark3/COMPONENT_VERSION"
|
||||
fi
|
||||
93
pi-gen/stageDangerousPi/02-pm3-install/00-run.sh
Executable file
93
pi-gen/stageDangerousPi/02-pm3-install/00-run.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
# Pre-chroot script: Source a pre-built PM3 tarball for installation.
|
||||
# This runs on the host before the chroot script.
|
||||
#
|
||||
# Tarball sources (checked in order):
|
||||
# 1. PM3_TARBALL env var (explicit path to a local tarball)
|
||||
# 2. Local file in this substage's files/ directory
|
||||
# 3. Download from GitHub Releases (dangerous-tacos/dangerous-pi)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# --- Detect Python ABI in chroot ---
|
||||
PYTHON_VERSION=$(chroot "${ROOTFS_DIR}" python3 -c \
|
||||
'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || echo "3.12")
|
||||
PYTHON_ABI="cp$(echo "$PYTHON_VERSION" | tr -d '.')"
|
||||
echo "Detected Python ${PYTHON_VERSION} (ABI: ${PYTHON_ABI}) in chroot"
|
||||
|
||||
# --- Read PM3 version config ---
|
||||
PM3_COMMIT=""
|
||||
if [ -f "${SCRIPT_DIR}/pm3-version.conf" ]; then
|
||||
. "${SCRIPT_DIR}/pm3-version.conf"
|
||||
fi
|
||||
|
||||
# --- Locate tarball ---
|
||||
TARBALL=""
|
||||
|
||||
# 1. Explicit env var
|
||||
if [ -n "${PM3_TARBALL:-}" ] && [ -f "$PM3_TARBALL" ]; then
|
||||
echo "Using PM3 tarball from PM3_TARBALL: $PM3_TARBALL"
|
||||
TARBALL="$PM3_TARBALL"
|
||||
fi
|
||||
|
||||
# 2. Local file in files/ directory
|
||||
if [ -z "$TARBALL" ]; then
|
||||
LOCAL_MATCH=$(ls "${SCRIPT_DIR}"/files/pm3-*-aarch64-linux-${PYTHON_ABI}.tar.gz 2>/dev/null | head -1)
|
||||
if [ -n "$LOCAL_MATCH" ]; then
|
||||
echo "Using local PM3 tarball: $LOCAL_MATCH"
|
||||
TARBALL="$LOCAL_MATCH"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Download from GitHub Releases
|
||||
if [ -z "$TARBALL" ]; then
|
||||
GH_REPO="dangerous-tacos/dangerous-pi"
|
||||
echo "Downloading PM3 tarball from GitHub Releases (${GH_REPO})..."
|
||||
|
||||
# Determine which release to fetch
|
||||
if [ -n "$PM3_COMMIT" ]; then
|
||||
# Try to find a release matching the commit
|
||||
RELEASE_URL="https://api.github.com/repos/${GH_REPO}/releases/latest"
|
||||
else
|
||||
RELEASE_URL="https://api.github.com/repos/${GH_REPO}/releases/latest"
|
||||
fi
|
||||
|
||||
# Get asset download URL for the matching ABI
|
||||
ASSET_PATTERN="pm3-.*-aarch64-linux-${PYTHON_ABI}\\.tar\\.gz"
|
||||
DOWNLOAD_URL=$(curl -sL "$RELEASE_URL" | \
|
||||
grep -oP "\"browser_download_url\":\\s*\"\\K[^\"]*${ASSET_PATTERN}" | head -1)
|
||||
|
||||
if [ -n "$DOWNLOAD_URL" ]; then
|
||||
TARBALL="/tmp/pm3-prebuilt-download.tar.gz"
|
||||
echo "Downloading: $DOWNLOAD_URL"
|
||||
curl -sL -o "$TARBALL" "$DOWNLOAD_URL"
|
||||
echo "Downloaded PM3 tarball ($(du -h "$TARBALL" | cut -f1))"
|
||||
else
|
||||
echo "WARNING: Could not find PM3 tarball for ABI ${PYTHON_ABI} in latest release"
|
||||
echo "Falling back to compile-from-source (this will take 45-60 min under QEMU)"
|
||||
echo "To avoid this, set PM3_TARBALL=/path/to/pm3-*.tar.gz"
|
||||
|
||||
# Copy patches and version config for the compile fallback
|
||||
if [ -f "${SCRIPT_DIR}/led-pwm-control.patch" ]; then
|
||||
cp "${SCRIPT_DIR}/led-pwm-control.patch" "${ROOTFS_DIR}/tmp/"
|
||||
fi
|
||||
if [ -f "${SCRIPT_DIR}/hf-booster-detection.patch" ]; then
|
||||
cp "${SCRIPT_DIR}/hf-booster-detection.patch" "${ROOTFS_DIR}/tmp/"
|
||||
fi
|
||||
if [ -f "${SCRIPT_DIR}/pm3-version.conf" ]; then
|
||||
cp "${SCRIPT_DIR}/pm3-version.conf" "${ROOTFS_DIR}/tmp/"
|
||||
fi
|
||||
touch "${ROOTFS_DIR}/tmp/PM3_COMPILE_FROM_SOURCE"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Copy tarball into rootfs ---
|
||||
cp "$TARBALL" "${ROOTFS_DIR}/tmp/pm3-prebuilt.tar.gz"
|
||||
echo "PM3 tarball staged at /tmp/pm3-prebuilt.tar.gz in rootfs"
|
||||
|
||||
# Clean up downloaded temp file
|
||||
if [ "${TARBALL}" = "/tmp/pm3-prebuilt-download.tar.gz" ]; then
|
||||
rm -f "$TARBALL"
|
||||
fi
|
||||
@@ -2,7 +2,7 @@ diff --git a/armsrc/appmain.c b/armsrc/appmain.c
|
||||
index 88ce070..cbeb7d2 100644
|
||||
--- a/armsrc/appmain.c
|
||||
+++ b/armsrc/appmain.c
|
||||
@@ -921,6 +921,70 @@ static void PacketReceived(PacketCommandNG *packet) {
|
||||
@@ -921,6 +921,100 @@ static void PacketReceived(PacketCommandNG *packet) {
|
||||
reply_ng(CMD_SET_TEAROFF, PM3_SUCCESS, NULL, 0);
|
||||
break;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ index 0df8935..6d8246f 100644
|
||||
|
||||
// As soon as our button let go, we didn't hold long enough
|
||||
if (BUTTON_PRESS() == false) {
|
||||
@@ -415,3 +415,90 @@ void convertToHexArray(uint32_t num, uint8_t *partialkey) {
|
||||
@@ -415,3 +415,241 @@ void convertToHexArray(uint32_t num, uint8_t *partialkey) {
|
||||
partialkey[i] = (uint8_t)strtoul(group, NULL, 2);
|
||||
}
|
||||
}
|
||||
@@ -409,7 +409,7 @@ diff --git a/armsrc/util.h b/armsrc/util.h
|
||||
index fd99ac7..d558dda 100644
|
||||
--- a/armsrc/util.h
|
||||
+++ b/armsrc/util.h
|
||||
@@ -105,4 +105,7 @@ bool data_available_fast(void);
|
||||
@@ -105,4 +105,13 @@ bool data_available_fast(void);
|
||||
uint32_t flash_size_from_cidr(uint32_t cidr);
|
||||
uint32_t get_flash_size(void);
|
||||
|
||||
@@ -427,7 +427,7 @@ diff --git a/client/src/cmdhw.c b/client/src/cmdhw.c
|
||||
index 9bbdc98..ad877b0 100644
|
||||
--- a/client/src/cmdhw.c
|
||||
+++ b/client/src/cmdhw.c
|
||||
@@ -1504,6 +1504,115 @@ int set_fpga_mode(uint8_t mode) {
|
||||
@@ -1504,6 +1504,193 @@ int set_fpga_mode(uint8_t mode) {
|
||||
return resp.status;
|
||||
}
|
||||
|
||||
@@ -719,7 +719,7 @@ diff --git a/include/pm3_cmd.h b/include/pm3_cmd.h
|
||||
index 860dfa9..e1551b5 100644
|
||||
--- a/include/pm3_cmd.h
|
||||
+++ b/include/pm3_cmd.h
|
||||
@@ -465,6 +465,13 @@ typedef struct {
|
||||
@@ -465,6 +465,17 @@ typedef struct {
|
||||
uint8_t data[];
|
||||
} PACKED smart_card_raw_t;
|
||||
|
||||
5
pi-gen/stageDangerousPi/02-pm3-install/pm3-version.conf
Normal file
5
pi-gen/stageDangerousPi/02-pm3-install/pm3-version.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
# Proxmark3 build version config
|
||||
# Change PM3_COMMIT to rebuild with a different upstream version.
|
||||
# Leave PM3_COMMIT empty to build from latest master (non-deterministic).
|
||||
PM3_REPO="https://github.com/RfidResearchGroup/proxmark3"
|
||||
PM3_COMMIT="ef82d5ba1"
|
||||
@@ -13,16 +13,8 @@ DEFAULT_HOME=$(eval echo ~$DEFAULT_USER)
|
||||
|
||||
echo "Detected default user: $DEFAULT_USER (home: $DEFAULT_HOME)"
|
||||
|
||||
# Install pip3 if not already installed
|
||||
if ! command -v pip3 &> /dev/null; then
|
||||
echo "Installing pip3..."
|
||||
apt-get update
|
||||
apt-get install -y python3-pip
|
||||
fi
|
||||
|
||||
# Install Node.js for frontend (Remix SSR) and utilities
|
||||
echo "Installing Node.js and utilities..."
|
||||
apt-get install -y nodejs npm lrzsz
|
||||
# Packages (nodejs, npm, lrzsz) installed by 00-apt-setup stage
|
||||
# pip3 is available from stage2 Python installation
|
||||
|
||||
# Install Python packages from requirements.txt
|
||||
pip3 install --break-system-packages -r /tmp/dangerous-pi-files/requirements.txt
|
||||
@@ -63,14 +55,28 @@ fi
|
||||
# Add user to netdev group for network management
|
||||
usermod -a -G netdev $DEFAULT_USER
|
||||
|
||||
# Build frontend (Remix SSR)
|
||||
echo "Building frontend..."
|
||||
# Frontend setup (Remix SSR)
|
||||
# build_frontend() in build-image.sh pre-builds on the host.
|
||||
# All production deps are pure JS (no native modules), so the host
|
||||
# node_modules works on arm64 without reinstalling.
|
||||
cd /opt/dangerous-pi/app/frontend
|
||||
npm ci --production=false # Install dev deps for build
|
||||
npm run build
|
||||
# Remove dev dependencies and node_modules bloat after build
|
||||
rm -rf node_modules/.cache
|
||||
npm prune --production
|
||||
if [ -d "build/server" ]; then
|
||||
echo "Using pre-built frontend from host..."
|
||||
# Remove build-time-only packages to save ~100MB of image space
|
||||
rm -rf node_modules/.cache \
|
||||
node_modules/@rollup \
|
||||
node_modules/@esbuild \
|
||||
node_modules/@remix-run/dev \
|
||||
node_modules/vite \
|
||||
node_modules/typescript \
|
||||
node_modules/@types
|
||||
else
|
||||
echo "Building frontend in chroot (no pre-built output found)..."
|
||||
npm install --production=false
|
||||
npm run build
|
||||
rm -rf node_modules/.cache
|
||||
npm prune --production
|
||||
fi
|
||||
cd /opt/dangerous-pi
|
||||
|
||||
# Install frontend systemd service
|
||||
@@ -26,4 +26,15 @@ echo "0.1.0-$(date +%Y%m%d)" > "${ROOTFS_DIR}/tmp/dangerous-pi-files/VERSION"
|
||||
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/data"
|
||||
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/logs"
|
||||
|
||||
# Copy dt-design-system to /opt/ so frontend's file: dependency resolves
|
||||
# Frontend package.json has: "@dangerousthings/web": "file:../../../dt-design-system/packages/web"
|
||||
# From /opt/dangerous-pi/app/frontend/, ../../../ resolves to /opt/
|
||||
if [ -d "${SCRIPT_DIR}/files/dt-design-system" ]; then
|
||||
echo "Copying dt-design-system for frontend dependency resolution..."
|
||||
mkdir -p "${ROOTFS_DIR}/opt/dt-design-system/packages"
|
||||
cp -r "${SCRIPT_DIR}/files/dt-design-system/packages/web" "${ROOTFS_DIR}/opt/dt-design-system/packages/"
|
||||
cp -r "${SCRIPT_DIR}/files/dt-design-system/packages/tokens" "${ROOTFS_DIR}/opt/dt-design-system/packages/"
|
||||
cp "${SCRIPT_DIR}/files/dt-design-system/package.json" "${ROOTFS_DIR}/opt/dt-design-system/"
|
||||
fi
|
||||
|
||||
echo "Files prepared successfully"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user