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:
michael
2026-03-04 12:01:01 -08:00
parent 2ec89041ef
commit a9acdb85ce
163 changed files with 8124 additions and 921 deletions

77
.github/workflows/base-image.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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*

View 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*

View 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*

View 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*

View File

@@ -3,13 +3,17 @@
Refactored to use services for business logic. Refactored to use services for business logic.
Session management uses PM3Service, system operations use SystemService. Session management uses PM3Service, system operations use SystemService.
""" """
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Dict from typing import Optional, Dict, List
from .. import config
from ..services.container import container from ..services.container import container
from ..managers.ups_manager import get_ups_manager from ..managers.ups_manager import get_ups_manager
from ..managers.ble_manager import get_ble_manager from ..managers.ble_manager import get_ble_manager
from ..managers.os_update_manager import get_os_update_manager
router = APIRouter() router = APIRouter()
@@ -676,14 +680,14 @@ async def get_ssl_info():
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"], ["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
capture_output=True, text=True, timeout=5 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" # Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
san_line = result.stdout.strip() san_line = result.stdout.strip()
for line in san_line.split("\n"): 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 # Split by comma and clean up
sans = [s.strip() for s in line.split(",")] 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 break
# Get SHA256 fingerprint # Get SHA256 fingerprint
@@ -738,10 +742,19 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
import subprocess import subprocess
import os 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 script_path:
if not os.path.exists(script_path):
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="SSL certificate generation script not found" detail="SSL certificate generation script not found"
@@ -975,4 +988,217 @@ async def clear_dismissed_widgets():
plugin_manager = get_plugin_manager() plugin_manager = get_plugin_manager()
plugin_manager.clear_dismissed() 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"} return {"success": True, "message": "Dismissed widgets cleared"}

View File

@@ -2,9 +2,11 @@
Refactored to use UpdateService for all business logic. Refactored to use UpdateService for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses. 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 typing import Optional, List
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Path
from pydantic import BaseModel from pydantic import BaseModel
from ..services.container import container from ..services.container import container
@@ -14,6 +16,31 @@ from ..managers.ble_manager import get_ble_manager, NotificationType
router = APIRouter() 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): class UpdateCheckResponse(BaseModel):
"""Response model for update check.""" """Response model for update check."""
update_available: bool update_available: bool
@@ -24,6 +51,8 @@ class UpdateCheckResponse(BaseModel):
is_prerelease: bool = False is_prerelease: bool = False
download_size: Optional[int] = None download_size: Optional[int] = None
message: Optional[str] = None message: Optional[str] = None
components: List[ComponentUpdateInfo] = []
plugins: List[PluginUpdateInfo] = []
class UpdateProgressResponse(BaseModel): class UpdateProgressResponse(BaseModel):
@@ -34,6 +63,8 @@ class UpdateProgressResponse(BaseModel):
download_progress: float = 0.0 download_progress: float = 0.0
error_message: Optional[str] = None error_message: Optional[str] = None
last_check: Optional[str] = None last_check: Optional[str] = None
active_component: Optional[str] = None
components: List[dict] = []
class ReleaseNotesRequest(BaseModel): class ReleaseNotesRequest(BaseModel):
@@ -41,15 +72,17 @@ class ReleaseNotesRequest(BaseModel):
version: Optional[str] = None 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: def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes. """Map service error codes to HTTP status codes."""
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = { codes = {
"update_check_error": 500, "update_check_error": 500,
"no_update_available": 400, "no_update_available": 400,
@@ -62,35 +95,44 @@ def _service_error_to_http_status(error_code: str) -> int:
"release_notes_error": 500, "release_notes_error": 500,
"check_download_error": 500, "check_download_error": 500,
"full_update_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) return codes.get(error_code, 500)
@router.get("/check", response_model=UpdateCheckResponse) def _raise_on_error(result):
async def check_for_updates(): """Raise HTTPException if result indicates failure."""
"""Check for available updates.
Uses UpdateService for business logic.
"""
result = await container.update_service.check_for_updates()
if not result.success: if not result.success:
raise HTTPException( raise HTTPException(
status_code=_service_error_to_http_status(result.error.code), 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"): if result.data.get("update_available"):
try: try:
ble_manager = get_ble_manager() ble_manager = get_ble_manager()
await ble_manager.send_notification( await ble_manager.send_notification(
NotificationType.UPDATE_AVAILABLE, NotificationType.UPDATE_AVAILABLE,
f"Update available: v{result.data['latest_version']}", f"Update available: v{result.data['latest_version']}",
{"version": result.data["latest_version"]} {"version": result.data["latest_version"]},
) )
except Exception: except Exception:
# BLE notification failure shouldn't affect the response
pass pass
return UpdateCheckResponse(**result.data) return UpdateCheckResponse(**result.data)
@@ -98,108 +140,126 @@ async def check_for_updates():
@router.get("/progress", response_model=UpdateProgressResponse) @router.get("/progress", response_model=UpdateProgressResponse)
async def get_update_progress(): async def get_update_progress():
"""Get current update progress. """Get current update progress."""
Uses UpdateService for business logic.
"""
result = await container.update_service.get_progress() result = await container.update_service.get_progress()
_raise_on_error(result)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return UpdateProgressResponse(**result.data) return UpdateProgressResponse(**result.data)
@router.post("/download") @router.post("/download")
async def download_update(): async def download_update(body: Optional[ComponentDownloadRequest] = None):
"""Download the available update. """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() result = await container.update_service.download_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
)
return {"message": result.data["message"]} return {"message": result.data["message"]}
@router.post("/install") @router.post("/install")
async def install_update(): async def install_update(body: Optional[ComponentDownloadRequest] = None):
"""Install the downloaded update. """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() result = await container.update_service.install_update()
_raise_on_error(result)
if not result.success: # BLE notification
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Send BLE notification
try: try:
ble_manager = get_ble_manager() ble_manager = get_ble_manager()
await ble_manager.send_notification( await ble_manager.send_notification(
NotificationType.UPDATE_COMPLETE, NotificationType.UPDATE_COMPLETE,
"Update installed successfully", "Update installed successfully",
{"restart_required": True} {"restart_required": True},
) )
except Exception: except Exception:
# BLE notification failure shouldn't affect the response
pass pass
return { return {
"message": result.data["message"], "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) @router.post("/release-notes", response_model=dict)
async def get_release_notes(request: ReleaseNotesRequest): async def get_release_notes(request: ReleaseNotesRequest):
"""Get release notes for a specific version. """Get release notes for a specific version."""
Uses UpdateService for business logic.
Args:
request: Version to get notes for (latest if not specified)
"""
result = await container.update_service.get_release_notes(request.version) result = await container.update_service.get_release_notes(request.version)
_raise_on_error(result)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return { return {
"version": result.data["version"], "version": result.data["version"],
"notes": result.data["release_notes"] "notes": result.data["release_notes"],
} }
@router.get("/current-version") @router.get("/current-version")
async def get_current_version(): async def get_current_version():
"""Get current system version. """Get current system version."""
Uses UpdateService for business logic.
"""
result = await container.update_service.get_progress() result = await container.update_service.get_progress()
_raise_on_error(result)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return { return {
"version": result.data["current_version"], "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

View File

@@ -30,6 +30,9 @@ VERSION = os.getenv("VERSION", "0.1.0")
# Update settings # Update settings
GITHUB_REPO = os.getenv("GITHUB_REPO", "dangerous-tacos/dangerous-pi") GITHUB_REPO = os.getenv("GITHUB_REPO", "dangerous-tacos/dangerous-pi")
UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour 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 # Wi-Fi settings
WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0") WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0")

View File

@@ -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(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(ws_router, prefix="/ws", tags=["websocket"]) 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 # Serve frontend static files if build directory exists
# In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build # 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 # Priority: 1) check relative to working directory, 2) check absolute production path

View File

@@ -210,7 +210,7 @@ class BLEManager:
} }
async def _check_bluetooth_adapter(self) -> bool: 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: Returns:
True if adapter is available, False otherwise True if adapter is available, False otherwise
@@ -224,8 +224,20 @@ class BLEManager:
) )
stdout, stderr = await process.communicate() stdout, stderr = await process.communicate()
# If we get output with "Controller", we have an adapter if b"Controller" not in stdout:
return b"Controller" 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: except FileNotFoundError:
logger.warning("bluetoothctl not found - BlueZ not installed") logger.warning("bluetoothctl not found - BlueZ not installed")
@@ -234,6 +246,19 @@ class BLEManager:
logger.error("Error checking Bluetooth adapter: %s", e) logger.error("Error checking Bluetooth adapter: %s", e)
return False 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): async def _set_device_name(self, name: str):
"""Set the Bluetooth device name. """Set the Bluetooth device name.

View 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

View File

@@ -8,6 +8,7 @@ import asyncio
import importlib.util import importlib.util
import inspect import inspect
import json import json
import re
import time import time
from dataclasses import dataclass, asdict, field from dataclasses import dataclass, asdict, field
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -16,6 +17,8 @@ from pathlib import Path
from typing import Optional, Dict, Any, List, Callable, Set from typing import Optional, Dict, Any, List, Callable, Set
import sys import sys
import aiohttp
from .. import config 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: class PluginManager:
"""Manages plugin loading, enabling, lifecycle, and header widgets.""" """Manages plugin loading, enabling, lifecycle, and header widgets."""
@@ -790,6 +805,121 @@ class PluginManager:
del self._header_widgets[widget_id] del self._header_widgets[widget_id]
print(f"Expired widget removed: {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 # Global plugin manager instance
_plugin_manager: Optional[PluginManager] = None _plugin_manager: Optional[PluginManager] = None

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,12 @@
This service provides software update operations that can be consumed by This service provides software update operations that can be consumed by
multiple interfaces (REST API, BLE GATT, etc.). 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 from .pm3_service import PM3ServiceError, PM3ServiceResult
@@ -156,6 +159,15 @@ class UpdateService:
try: try:
progress = await self.update_manager.get_progress() 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( return PM3ServiceResult(
success=True, success=True,
data={ data={
@@ -164,7 +176,9 @@ class UpdateService:
"available_version": progress.available_version, "available_version": progress.available_version,
"download_progress": progress.download_progress, "download_progress": progress.download_progress,
"error_message": progress.error_message, "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) 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)
)
)

View File

@@ -33,7 +33,7 @@ export default function ConnectDialog({ network, onClose, isSubmitting }: Connec
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: "rgba(10, 14, 26, 0.9)", background: "rgba(var(--color-bg-rgb), 0.9)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",

View File

@@ -279,7 +279,7 @@ export default function DeviceSelector({
style={{ style={{
marginBottom: "var(--space-3)", marginBottom: "var(--space-3)",
padding: "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)", borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)", borderRadius: "var(--radius)",
}} }}
@@ -318,7 +318,7 @@ export default function DeviceSelector({
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed", cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
opacity: isSelectable || isFlashing ? 1 : 0.6, opacity: isSelectable || isFlashing ? 1 : 0.6,
transition: "all 150ms ease", 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", position: "relative",
}} }}
onClick={() => { onClick={() => {
@@ -336,7 +336,7 @@ export default function DeviceSelector({
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: "rgba(0, 0, 0, 0.8)", background: "rgba(var(--color-bg-rgb), 0.8)",
borderRadius: "var(--radius)", borderRadius: "var(--radius)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -442,7 +442,7 @@ export default function DeviceSelector({
style={{ style={{
marginTop: "var(--space-2)", marginTop: "var(--space-2)",
padding: "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)", borderLeft: "3px solid var(--color-warning)",
borderRadius: "var(--radius)", borderRadius: "var(--radius)",
fontSize: "0.75rem", fontSize: "0.75rem",
@@ -538,7 +538,7 @@ export default function DeviceSelector({
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: "rgba(0, 0, 0, 0.75)", background: "rgba(var(--color-bg-rgb), 0.75)",
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
@@ -600,7 +600,7 @@ export default function DeviceSelector({
<div <div
style={{ style={{
background: "rgba(239, 68, 68, 0.1)", background: "rgba(var(--color-error-rgb), 0.1)",
borderLeft: "3px solid var(--color-error)", borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)", borderRadius: "var(--radius)",
padding: "var(--space-3)", padding: "var(--space-3)",

View File

@@ -30,7 +30,7 @@ export function LoginPrompt() {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
background: "rgba(0, 0, 0, 0.75)", background: "rgba(var(--color-bg-rgb), 0.75)",
backdropFilter: "blur(4px)", backdropFilter: "blur(4px)",
}} }}
> >

View File

@@ -155,7 +155,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: "rgba(10, 14, 26, 0.95)", background: "rgba(var(--color-bg-rgb), 0.95)",
backdropFilter: "blur(8px)", backdropFilter: "blur(8px)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -203,7 +203,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
<div <div
style={{ style={{
padding: "var(--space-3)", 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)", border: "1px solid var(--color-error)",
borderRadius: "var(--radius)", borderRadius: "var(--radius)",
marginBottom: "var(--space-4)", marginBottom: "var(--space-4)",

View 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;
}

View File

@@ -7,27 +7,40 @@ import {
ScrollRestoration, ScrollRestoration,
useLocation, useLocation,
} from "@remix-run/react"; } from "@remix-run/react";
import { useState, useEffect } from "react"; import baseStyles from "./styles/base.css?url";
import styles from "./styles.css?url"; import themeStyles from "@dangerousthings/web/dist/index.css?url";
import { PowerWidget } from "./components/PowerWidget"; import { PowerWidget } from "./components/PowerWidget";
import { HeaderWidgets } from "./components/HeaderWidgets"; import { HeaderWidgets } from "./components/HeaderWidgets";
import { LoginPrompt } from "./components/LoginPrompt"; import { LoginPrompt } from "./components/LoginPrompt";
import { useWebSocketState } from "./hooks/useWebSocket"; import { useWebSocketState } from "./hooks/useWebSocket";
import { ThemeProvider, useTheme } from "./lib/ThemeContext";
import type { ThemeMode } from "./lib/ThemeContext";
export const links: LinksFunction = () => [ 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 }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" data-brand="dt" data-theme="dark">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" /> <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 name="description" content="Dangerous Pi - Proxmark3 Management Interface" />
<Meta /> <Meta />
<Links /> <Links />
{/* Prevent FOUC: hardcoded constant string, no user input — safe from XSS */}
<script dangerouslySetInnerHTML={{ __html: FOUC_PREVENTION_SCRIPT }} />
</head> </head>
<body> <body>
{children} {children}
@@ -39,40 +52,30 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export default function App() { export default function App() {
return (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}
function AppInner() {
const location = useLocation(); const location = useLocation();
const wsState = useWebSocketState(); 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) const toggleMode = () => {
useEffect(() => { const modes: ThemeMode[] = ["dark", "auto", "light"];
const savedTheme = localStorage.getItem("theme") as "auto" | "dark" | "light" | null; const currentIndex = modes.indexOf(mode);
setMode(modes[(currentIndex + 1) % modes.length]);
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 navLinks = [ const navLinks = [
{ to: "/", label: "Dashboard", icon: "" }, { to: "/", label: "Dashboard", icon: "\u25C8" },
{ to: "/commands", label: "Commands", icon: "" }, { to: "/commands", label: "Commands", icon: "\u25B6" },
{ to: "/settings", label: "Settings", icon: "" }, { to: "/settings", label: "Settings", icon: "\u2699" },
{ to: "/updates", label: "Updates", icon: "🔄" }, { to: "/updates", label: "Updates", icon: "\uD83D\uDD04" },
{ to: "/logs", label: "Logs", icon: "" }, { to: "/logs", label: "Logs", icon: "\u2261" },
]; ];
return ( return (
@@ -87,13 +90,13 @@ export default function App() {
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<PowerWidget /> <PowerWidget />
<button <button
onClick={toggleTheme} onClick={toggleMode}
className="btn-secondary" className="btn-secondary"
style={{ minWidth: "44px", padding: "0.5rem" }} style={{ minWidth: "44px", padding: "0.5rem" }}
aria-label="Toggle theme" aria-label="Toggle theme mode"
title={`Current theme: ${theme}`} title={`Current mode: ${mode}`}
> >
{theme === "dark" ? "" : theme === "light" ? "" : ""} {mode === "dark" ? "\u25D0" : mode === "light" ? "\u25CB" : "\u25D1"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -353,7 +353,7 @@ export default function Dashboard() {
<strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version} <strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version}
</p> </p>
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}> <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> </p>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,8 @@ import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData, useNavigation, useFetcher } from "@remix-run/react"; import { Form, useActionData, useLoaderData, useNavigation, useFetcher } from "@remix-run/react";
import { useState, useEffect, lazy, Suspense } from "react"; import { useState, useEffect, lazy, Suspense } from "react";
import ConnectDialog from "~/components/ConnectDialog"; import ConnectDialog from "~/components/ConnectDialog";
import { useTheme } from "~/lib/ThemeContext";
import type { ThemeBrand, ThemeMode } from "~/lib/ThemeContext";
// Lazy load QR Scanner // Lazy load QR Scanner
const QRScanner = lazy(() => import("~/components/QRScanner")); 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 { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const navigation = useNavigation(); 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 [scannedNetworks, setScannedNetworks] = useState<any[]>([]);
const [selectedNetwork, setSelectedNetwork] = useState<any>(null); const [selectedNetwork, setSelectedNetwork] = useState<any>(null);
const [showConnectDialog, setShowConnectDialog] = useState(false); const [showConnectDialog, setShowConnectDialog] = useState(false);
@@ -336,6 +339,7 @@ export default function Settings() {
{ id: "plugins", label: "Plugins", icon: "🔌" }, { id: "plugins", label: "Plugins", icon: "🔌" },
{ id: "advanced", label: "Advanced", icon: "◈" }, { id: "advanced", label: "Advanced", icon: "◈" },
{ id: "system", label: "System", icon: "⚡" }, { id: "system", label: "System", icon: "⚡" },
{ id: "appearance", label: "Theme", icon: "◐" },
]; ];
const getSignalIcon = (strength: number) => { const getSignalIcon = (strength: number) => {
@@ -348,10 +352,6 @@ export default function Settings() {
return ( return (
<div className="container"> <div className="container">
<h1 style={{ marginBottom: "var(--space-6)" }}>
<span style={{ color: "var(--color-secondary)" }}></span> Settings
</h1>
{actionData && "message" in actionData && ( {actionData && "message" in actionData && (
<div <div
className="card" className="card"
@@ -369,8 +369,11 @@ export default function Settings() {
</div> </div>
)} )}
{/* Section Tabs */} {/* Settings Header + Section Tabs */}
<div className="card" style={{ marginBottom: "var(--space-4)", padding: "var(--space-2)" }}> <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)" }}> <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "var(--space-2)" }}>
{sections.map((section) => ( {sections.map((section) => (
<button <button
@@ -775,7 +778,7 @@ export default function Settings() {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
background: "rgba(10, 14, 26, 0.95)", background: "rgba(var(--color-bg-rgb), 0.95)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -965,6 +968,52 @@ export default function Settings() {
</div> </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 */} {/* System Actions */}
{activeSection === "system" && ( {activeSection === "system" && (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}> <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-4)" }}>

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,10 @@
/* Dangerous Pi - Cyberpunk Theme */ /* Dangerous Pi - Base Styles (Theme-Agnostic) */
/* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */ /* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */
/* ============================================================================
Default token values (overridden by theme files via data-brand attribute)
============================================================================ */
:root { :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) */ /* Spacing (4px base) */
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
@@ -37,57 +18,25 @@
--radius: 0.5rem; --radius: 0.5rem;
--radius-lg: 0.75rem; --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 */ /* Transitions */
--transition-fast: 150ms ease; --transition-fast: 150ms ease;
--transition: 250ms 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-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --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) { Reset & Base
: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 */
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -95,7 +44,7 @@
} }
html { html {
font-family: var(--font-sans); font-family: var(--font-body);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -109,17 +58,20 @@ body {
min-height: 100vh; min-height: 100vh;
} }
/* Typography */ /* ============================================================================
Typography
============================================================================ */
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 600; font-weight: 600;
line-height: 1.25; line-height: 1.25;
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
h1 { font-size: 1.875rem; } /* 30px */ h1 { font-size: 1.875rem; }
h2 { font-size: 1.5rem; } /* 24px */ h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; } /* 20px */ h3 { font-size: 1.25rem; }
h4 { font-size: 1.125rem; } /* 18px */ h4 { font-size: 1.125rem; }
p { p {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@@ -135,7 +87,9 @@ a:hover {
color: var(--color-accent); color: var(--color-accent);
} }
/* Layout */ /* ============================================================================
Layout
============================================================================ */
.container { .container {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
@@ -151,7 +105,7 @@ a:hover {
top: 0; top: 0;
z-index: 100; z-index: 100;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
background: rgba(18, 24, 39, 0.9); background: rgba(var(--color-surface-rgb), 0.9);
} }
.header-content { .header-content {
@@ -166,6 +120,7 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
font-family: var(--font-heading);
font-weight: 700; font-weight: 700;
font-size: 1.25rem; font-size: 1.25rem;
color: var(--color-primary); color: var(--color-primary);
@@ -180,7 +135,9 @@ a:hover {
border-radius: var(--radius); border-radius: var(--radius);
} }
/* Navigation */ /* ============================================================================
Navigation
============================================================================ */
.nav { .nav {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
@@ -204,7 +161,7 @@ a:hover {
.nav-link.active { .nav-link.active {
color: var(--color-primary); color: var(--color-primary);
border-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 */ /* Mobile Navigation */
@@ -230,7 +187,7 @@ a:hover {
} }
body { body {
padding-bottom: 60px; /* Space for bottom nav */ padding-bottom: 60px;
} }
} }
@@ -241,7 +198,9 @@ a:hover {
margin: 0 auto; margin: 0 auto;
} }
/* Cards */ /* ============================================================================
Cards
============================================================================ */
.card { .card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -255,6 +214,7 @@ a:hover {
} }
.card-title { .card-title {
font-family: var(--font-heading);
font-size: 1.125rem; font-size: 1.125rem;
font-weight: 600; font-weight: 600;
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
@@ -267,7 +227,9 @@ a:hover {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
} }
/* Buttons */ /* ============================================================================
Buttons
============================================================================ */
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -275,6 +237,7 @@ a:hover {
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-3) var(--space-6); padding: var(--space-3) var(--space-6);
border-radius: var(--radius); border-radius: var(--radius);
font-family: var(--font-body);
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
text-transform: uppercase; text-transform: uppercase;
@@ -282,21 +245,21 @@ a:hover {
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1px solid transparent;
transition: all var(--transition-fast); transition: all var(--transition-fast);
min-height: 44px; /* Touch-friendly */ min-height: 44px;
font-family: var(--font-sans);
} }
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--color-primary);
color: var(--color-bg); color: var(--color-bg);
border-color: var(--color-primary); 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 { .btn-primary:hover {
background: var(--color-accent); background: var(--color-accent);
color: var(--color-bg);
border-color: var(--color-accent); 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); transform: translateY(-1px);
} }
@@ -307,19 +270,20 @@ a:hover {
} }
.btn-secondary:hover { .btn-secondary:hover {
background: rgba(0, 255, 255, 0.1); background: rgba(var(--color-primary-rgb), 0.1);
border-color: var(--color-accent); border-color: var(--color-accent);
color: var(--color-accent); color: var(--color-accent);
} }
.btn-danger { .btn-danger {
background: var(--color-error); background: var(--color-error);
color: white; color: var(--color-bg);
border-color: var(--color-error); border-color: var(--color-error);
} }
.btn-danger:hover { .btn-danger:hover {
background: #cc0044; color: var(--color-bg);
filter: brightness(0.85);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -329,7 +293,9 @@ a:hover {
transform: none !important; transform: none !important;
} }
/* Status Badges */ /* ============================================================================
Status Badges
============================================================================ */
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -343,25 +309,25 @@ a:hover {
} }
.badge-success { .badge-success {
background: rgba(0, 255, 136, 0.2); background: rgba(var(--color-success-rgb), 0.2);
color: var(--color-success); color: var(--color-success);
border: 1px solid var(--color-success); border: 1px solid var(--color-success);
} }
.badge-error { .badge-error {
background: rgba(255, 0, 85, 0.2); background: rgba(var(--color-error-rgb), 0.2);
color: var(--color-error); color: var(--color-error);
border: 1px solid var(--color-error); border: 1px solid var(--color-error);
} }
.badge-warning { .badge-warning {
background: rgba(255, 170, 0, 0.2); background: rgba(var(--color-warning-rgb), 0.2);
color: var(--color-warning); color: var(--color-warning);
border: 1px solid var(--color-warning); border: 1px solid var(--color-warning);
} }
.badge-info { .badge-info {
background: rgba(0, 255, 255, 0.2); background: rgba(var(--color-info-rgb), 0.2);
color: var(--color-info); color: var(--color-info);
border: 1px solid var(--color-info); border: 1px solid var(--color-info);
} }
@@ -375,7 +341,9 @@ a:hover {
50% { opacity: 0.6; } 50% { opacity: 0.6; }
} }
/* Forms */ /* ============================================================================
Forms
============================================================================ */
.form-group { .form-group {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
@@ -406,14 +374,16 @@ a:hover {
.input:focus { .input:focus {
outline: none; outline: none;
border-color: var(--color-primary); 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 { .input::placeholder {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* Terminal/Code Output */ /* ============================================================================
Terminal / Code Output
============================================================================ */
.terminal { .terminal {
background: var(--color-bg); background: var(--color-bg);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -446,7 +416,9 @@ a:hover {
background: var(--color-primary); background: var(--color-primary);
} }
/* Loading States */ /* ============================================================================
Loading States
============================================================================ */
.skeleton { .skeleton {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
@@ -477,7 +449,9 @@ a:hover {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Toast Notifications */ /* ============================================================================
Toast Notifications
============================================================================ */
.toast { .toast {
position: fixed; position: fixed;
bottom: var(--space-6); bottom: var(--space-6);
@@ -502,7 +476,9 @@ a:hover {
} }
} }
/* Utility Classes */ /* ============================================================================
Utility Classes
============================================================================ */
.text-primary { color: var(--color-text-primary); } .text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); } .text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); } .text-muted { color: var(--color-text-muted); }
@@ -547,7 +523,9 @@ a:hover {
outline-offset: 2px; outline-offset: 2px;
} }
/* Responsive */ /* ============================================================================
Responsive
============================================================================ */
@media (max-width: 768px) { @media (max-width: 768px) {
h1 { font-size: 1.5rem; } h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; } h2 { font-size: 1.25rem; }
@@ -561,7 +539,9 @@ a:hover {
} }
} }
/* Power Widget */ /* ============================================================================
Power Widget
============================================================================ */
.power-widget { .power-widget {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -609,12 +589,8 @@ a:hover {
} }
@keyframes plug-pulse { @keyframes plug-pulse {
0%, 100% { 0%, 100% { opacity: 1; }
opacity: 1; 50% { opacity: 0.6; }
}
50% {
opacity: 0.6;
}
} }
/* Battery Icon */ /* Battery Icon */
@@ -657,7 +633,7 @@ a:hover {
.power-widget--charging { .power-widget--charging {
color: var(--color-accent); color: var(--color-accent);
border-color: rgba(0, 255, 136, 0.3); border-color: rgba(var(--color-accent-rgb), 0.3);
} }
.power-widget--full { .power-widget--full {
@@ -667,23 +643,23 @@ a:hover {
.power-widget--low { .power-widget--low {
color: var(--color-warning); color: var(--color-warning);
border-color: rgba(255, 170, 0, 0.3); border-color: rgba(var(--color-warning-rgb), 0.3);
} }
.power-widget--critical { .power-widget--critical {
color: var(--color-error); 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; animation: critical-pulse 1s ease-in-out infinite;
} }
@keyframes critical-pulse { @keyframes critical-pulse {
0%, 100% { 0%, 100% {
border-color: rgba(255, 0, 85, 0.3); border-color: rgba(var(--color-error-rgb), 0.3);
box-shadow: none; box-shadow: none;
} }
50% { 50% {
border-color: var(--color-error); 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 - Notification banners below header
============================================================================ */ ============================================================================ */
.header-widgets { .header-widgets {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -733,25 +708,25 @@ a:hover {
} }
.widget-info { .widget-info {
background: rgba(0, 255, 255, 0.1); background: rgba(var(--color-info-rgb), 0.1);
border-left: 3px solid var(--color-info); border-left: 3px solid var(--color-info);
color: var(--color-info); color: var(--color-info);
} }
.widget-warning { .widget-warning {
background: rgba(255, 170, 0, 0.1); background: rgba(var(--color-warning-rgb), 0.1);
border-left: 3px solid var(--color-warning); border-left: 3px solid var(--color-warning);
color: var(--color-warning); color: var(--color-warning);
} }
.widget-error { .widget-error {
background: rgba(255, 0, 85, 0.1); background: rgba(var(--color-error-rgb), 0.1);
border-left: 3px solid var(--color-error); border-left: 3px solid var(--color-error);
color: var(--color-error); color: var(--color-error);
} }
.widget-success { .widget-success {
background: rgba(0, 255, 136, 0.1); background: rgba(var(--color-success-rgb), 0.1);
border-left: 3px solid var(--color-success); border-left: 3px solid var(--color-success);
color: var(--color-success); color: var(--color-success);
} }
@@ -769,7 +744,7 @@ a:hover {
.widget-action { .widget-action {
padding: var(--space-1) var(--space-3); 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); border-radius: var(--radius-sm);
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
@@ -780,7 +755,7 @@ a:hover {
} }
.widget-action:hover { .widget-action:hover {
background: rgba(255, 255, 255, 0.2); background: rgba(var(--color-primary-rgb), 0.2);
} }
.widget-dismiss { .widget-dismiss {

View File

@@ -8,6 +8,7 @@
"name": "dangerous-pi-frontend", "name": "dangerous-pi-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
"@remix-run/node": "^2.14.0", "@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0", "@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0", "@remix-run/serve": "^2.14.0",
@@ -26,6 +27,22 @@
"node": ">=18.0.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -515,6 +532,10 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dangerousthings/web": {
"resolved": "../../../dt-design-system/packages/web",
"link": true
},
"node_modules/@emotion/hash": { "node_modules/@emotion/hash": {
"version": "0.9.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",

View File

@@ -13,6 +13,7 @@
"@remix-run/node": "^2.14.0", "@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0", "@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0", "@remix-run/serve": "^2.14.0",
"@dangerousthings/web": "file:../../../dt-design-system/packages/web",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View 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"
}

View File

@@ -0,0 +1,5 @@
/*
* Classic Cyberpunk — Token CSS
*
* Generated by @dangerousthings/tokens during CI builds.
*/

View 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"
}

View 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.
*/

View 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

View 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": []
}

View 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);
}

View File

@@ -1,5 +1,16 @@
#!/bin/bash #!/bin/bash
# Build Dangerous Pi image using pi-gen # 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 set -e
# Colors for output # Colors for output
@@ -10,12 +21,11 @@ NC='\033[0m' # No Color
# Configuration # Configuration
PI_GEN_DIR="/home/work/pi-gen-builder" # Official pi-gen builder PI_GEN_DIR="/home/work/pi-gen-builder" # Official pi-gen builder
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PM3_STAGE_DIR="$SCRIPT_DIR/pi-gen/stagePM3"
DTPI_STAGE_DIR="$SCRIPT_DIR/pi-gen/stageDangerousPi" DTPI_STAGE_DIR="$SCRIPT_DIR/pi-gen/stageDangerousPi"
CONFIG_FILE="$SCRIPT_DIR/pi-gen/config" CONFIG_FILE="$SCRIPT_DIR/pi-gen/config"
KEEP_CONTAINER=0 KEEP_CONTAINER=0
# Parse flags # Parse flags (after build type positional arg)
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
--keep-container) --keep-container)
@@ -57,7 +67,7 @@ build_frontend() {
echo -e "${GREEN}Running npm build...${NC}" echo -e "${GREEN}Running npm build...${NC}"
npm run build npm run build
echo -e "${GREEN}Frontend build complete${NC}" echo -e "${GREEN}Frontend build complete${NC}"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
return 0 return 0
} }
@@ -69,10 +79,6 @@ copy_stages() {
# Copy config # Copy config
cp "$CONFIG_FILE" config cp "$CONFIG_FILE" config
# Copy PM3 stage
rm -rf stagePM3
cp -r "$PM3_STAGE_DIR" .
# Copy DangerousPi stage # Copy DangerousPi stage
rm -rf stageDangerousPi rm -rf stageDangerousPi
cp -r "$DTPI_STAGE_DIR" . cp -r "$DTPI_STAGE_DIR" .
@@ -92,11 +98,56 @@ copy_stages() {
# Copy Dangerous Pi source files into the stage # Copy Dangerous Pi source files into the stage
echo -e "${GREEN}Copying Dangerous Pi source files...${NC}" echo -e "${GREEN}Copying Dangerous Pi source files...${NC}"
mkdir -p stageDangerousPi/03-dangerous-pi/files mkdir -p stageDangerousPi/04-dangerous-pi/files
cp -r "$SCRIPT_DIR/app" stageDangerousPi/03-dangerous-pi/files/ cp -r "$SCRIPT_DIR/app" stageDangerousPi/04-dangerous-pi/files/
cp -r "$SCRIPT_DIR/systemd" stageDangerousPi/03-dangerous-pi/files/ cp -r "$SCRIPT_DIR/systemd" stageDangerousPi/04-dangerous-pi/files/
cp -r "$SCRIPT_DIR/scripts" stageDangerousPi/03-dangerous-pi/files/ cp -r "$SCRIPT_DIR/scripts" stageDangerousPi/04-dangerous-pi/files/
cp "$SCRIPT_DIR/requirements.txt" stageDangerousPi/03-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 # Build type
@@ -114,49 +165,36 @@ case "$BUILD_TYPE" in
copy_stages copy_stages
# Remove any SKIP files to ensure full build # Use pre-baked base image if available, otherwise build all stages
rm -f stage0/SKIP stage1/SKIP stage2/SKIP stagePM3/SKIP stageDangerousPi/SKIP 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
fi
rm -f stageDangerousPi/SKIP
# Full build (uses docker, no root needed) # Full build (uses docker, no root needed)
PIGEN_DOCKER_MODE=1 ./build-docker.sh 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) from-dtpi)
echo -e "${GREEN}Building from DangerousPi stage (customizations only)...${NC}" echo -e "${GREEN}Building from DangerousPi stage (customizations only)...${NC}"
copy_stages copy_stages
# Check for cached PM3 stage # Check for cached stage2
if ls work/*/stagePM3/*.qcow2 2>/dev/null | grep -q .; then if ls work/*/stage2/*.qcow2 2>/dev/null | grep -q . || \
echo -e "${GREEN}Found cached stagePM3, creating SKIP markers...${NC}" [ -d "work/dangerous-pi/stage2/rootfs" ]; then
touch stage0/SKIP stage1/SKIP stage2/SKIP stagePM3/SKIP echo -e "${GREEN}Found cached base stages, creating SKIP markers...${NC}"
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}"
touch stage0/SKIP stage1/SKIP stage2/SKIP 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 fi
# Ensure DangerousPi stage builds # Ensure DangerousPi stage builds
@@ -170,10 +208,10 @@ case "$BUILD_TYPE" in
echo -e "${YELLOW}Removing ALL build cache and container...${NC}" 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 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 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 else
docker volume rm pigen_work > /dev/null 2>&1 || true docker volume rm pigen_work > /dev/null 2>&1 || true
echo -e "${GREEN}Removed volume${NC}" echo -e "${GREEN}Removed volume${NC}"
fi fi
echo "" echo ""
echo "Cache wiped. Next build will be from scratch." echo "Cache wiped. Next build will be from scratch."
@@ -183,46 +221,50 @@ case "$BUILD_TYPE" in
test) test)
echo -e "${GREEN}Validating stage scripts...${NC}" echo -e "${GREEN}Validating stage scripts...${NC}"
# Syntax check - PM3 build # Syntax check - PM3 install
bash -n "$PM3_STAGE_DIR/01-proxmark3/00-run-chroot.sh" 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 # Syntax check - WiFi AP
bash -n "$DTPI_STAGE_DIR/01-Wireless-AP/00-run-chroot.sh" bash -n "$DTPI_STAGE_DIR/01-Wireless-AP/00-run-chroot.sh"
# Syntax check - ttyd # 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 # Syntax check - Dangerous Pi
bash -n "$DTPI_STAGE_DIR/03-dangerous-pi/00-run.sh" bash -n "$DTPI_STAGE_DIR/04-dangerous-pi/00-run.sh"
bash -n "$DTPI_STAGE_DIR/03-dangerous-pi/00-run-chroot.sh" bash -n "$DTPI_STAGE_DIR/04-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/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 # Check required files exist
[ -d "$(dirname "$0")/app" ] || { echo "Error: app/ directory not found"; exit 1; } [ -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; } [ -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; } [ -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 ""
echo "Build Commands:" 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 " Use for: First build or when base system needs updates"
echo " Preserves cache for fast rebuilds" echo " Preserves cache for fast rebuilds"
echo "" echo ""
echo " from-pm3 - Rebuild PM3 + customizations (~15 min with cache)" echo " from-dtpi - Rebuild only customizations (~5 min)"
echo " Use for: Updating Proxmark3 firmware/client"
echo " Skips: stage0, stage1, stage2 (uses cache)"
echo ""
echo " from-dtpi - Rebuild only customizations (~5 min) ⭐"
echo " Use for: Daily development, app changes" echo " Use for: Daily development, app changes"
echo " Skips: stage0, stage1, stage2, stagePM3 (uses cache)" echo " Skips: stages 0-2, PM3 (uses cache)"
echo " Requires: Previous 'full' or 'from-pm3' build" echo " Requires: Previous 'full' build"
echo "" echo ""
echo "Utility Commands:" echo "Utility Commands:"
echo " test - Validate all scripts without building" echo " test - Validate all scripts without building"
@@ -230,16 +272,22 @@ case "$BUILD_TYPE" in
echo "" echo ""
echo "Flags:" echo "Flags:"
echo " --keep-container - Keep Docker container after build (for debugging)" echo " --keep-container - Keep Docker container after build (for debugging)"
echo " Default: Auto-cleanup container, preserve cache"
echo "" echo ""
echo "Cache Info:" echo "Environment Variables:"
echo " All builds now preserve cache in Docker volume 'pigen_work'" echo " PM3_TARBALL Path to pre-built PM3 tarball (aarch64)"
echo " Use 'clean' only if you need to completely start over" 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 ""
echo "Stage Structure:" echo "Stage Structure:"
echo " stage0, stage1, stage2 Base Raspberry Pi OS (cached)" echo " stage0, stage1, stage2 -> Base Raspberry Pi OS (cached)"
echo " stagePM3 → Proxmark3 build (cached)" echo " stageDangerousPi -> PM3 + WiFi AP + ttyd + app + UPS + HTTPS + updates"
echo " stageDangerousPi → WiFi AP + ttyd + your app"
exit 1 exit 1
;; ;;
esac 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 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}" echo -e "${GREEN}Cleaning up build container (preserving cache)...${NC}"
docker rm pigen_work > /dev/null 2>&1 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 fi
else else
echo -e "${YELLOW}Container kept for debugging (--keep-container flag)${NC}" echo -e "${YELLOW}Container kept for debugging (--keep-container flag)${NC}"

42
ci/build-backend.sh Executable file
View 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
View 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
View 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
View 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
View 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()

View 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
View 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.

View File

@@ -13,4 +13,4 @@ FIRST_USER_PASS="proxmark3"
ENABLE_SSH=1 ENABLE_SSH=1
WPA_COUNTRY="US" WPA_COUNTRY="US"
DISABLE_FIRST_BOOT_USER_RENAME=1 DISABLE_FIRST_BOOT_USER_RENAME=1
STAGE_LIST="stage0 stage1 stage2 stagePM3 stageDangerousPi" STAGE_LIST="stage0 stage1 stage2 stageDangerousPi"

View 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

View File

@@ -7,13 +7,7 @@
echo "=== Setting up WiFi Access Point ===" echo "=== Setting up WiFi Access Point ==="
# Install required packages # Packages (hostapd, dnsmasq, nftables, nginx) installed by 00-apt-setup stage
echo "Installing hostapd, dnsmasq, nginx..."
apt-get install -y \
hostapd \
dnsmasq \
nftables \
nginx
# Note: We no longer use static config file for wlan0 management. # Note: We no longer use static config file for wlan0 management.
# The wifi_manager now uses 'nmcli device set wlan0 managed yes/no' dynamically. # The wifi_manager now uses 'nmcli device set wlan0 managed yes/no' dynamically.

View File

@@ -6,9 +6,25 @@
echo "=== Starting Proxmark3 build with Python bindings ===" 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 # Install build dependencies
# apt-get update not needed: metadata inherited from stage0 (same build session)
echo "Installing build dependencies..." echo "Installing build dependencies..."
apt-get update
apt-get install -y \ apt-get install -y \
cmake \ cmake \
python3-dev \ python3-dev \
@@ -29,12 +45,23 @@ apt-get install -y \
libbluetooth-dev \ libbluetooth-dev \
libgd-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 # Clone Proxmark3 repository
echo "Cloning Proxmark3 repository..." echo "Cloning Proxmark3 repository..."
cd /tmp cd /tmp
rm -rf proxmark3 # Clean any existing clone rm -rf proxmark3 # Clean any existing clone
git clone https://github.com/RfidResearchGroup/proxmark3 git clone "$PM3_REPO" proxmark3
cd 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) # Apply LED PWM control patch (custom Dangerous Pi feature)
# Patch was copied to /tmp by 00-run.sh (pre-chroot script) # Patch was copied to /tmp by 00-run.sh (pre-chroot script)
@@ -368,6 +395,13 @@ else
echo "✗ WARNING: Rebuild script not installed" echo "✗ WARNING: Rebuild script not installed"
fi 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 # Print summary
echo "=== Proxmark3 build complete ===" echo "=== Proxmark3 build complete ==="
echo "Installation location: $DEFAULT_HOME/.pm3/proxmark3/" echo "Installation location: $DEFAULT_HOME/.pm3/proxmark3/"

View 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

View 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

View File

@@ -2,7 +2,7 @@ diff --git a/armsrc/appmain.c b/armsrc/appmain.c
index 88ce070..cbeb7d2 100644 index 88ce070..cbeb7d2 100644
--- a/armsrc/appmain.c --- a/armsrc/appmain.c
+++ b/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); reply_ng(CMD_SET_TEAROFF, PM3_SUCCESS, NULL, 0);
break; break;
} }
@@ -163,7 +163,7 @@ index 0df8935..6d8246f 100644
// As soon as our button let go, we didn't hold long enough // As soon as our button let go, we didn't hold long enough
if (BUTTON_PRESS() == false) { 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); 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 index fd99ac7..d558dda 100644
--- a/armsrc/util.h --- a/armsrc/util.h
+++ b/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 flash_size_from_cidr(uint32_t cidr);
uint32_t get_flash_size(void); 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 index 9bbdc98..ad877b0 100644
--- a/client/src/cmdhw.c --- a/client/src/cmdhw.c
+++ b/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; return resp.status;
} }
@@ -719,7 +719,7 @@ diff --git a/include/pm3_cmd.h b/include/pm3_cmd.h
index 860dfa9..e1551b5 100644 index 860dfa9..e1551b5 100644
--- a/include/pm3_cmd.h --- a/include/pm3_cmd.h
+++ b/include/pm3_cmd.h +++ b/include/pm3_cmd.h
@@ -465,6 +465,13 @@ typedef struct { @@ -465,6 +465,17 @@ typedef struct {
uint8_t data[]; uint8_t data[];
} PACKED smart_card_raw_t; } PACKED smart_card_raw_t;

View 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"

View File

@@ -13,16 +13,8 @@ DEFAULT_HOME=$(eval echo ~$DEFAULT_USER)
echo "Detected default user: $DEFAULT_USER (home: $DEFAULT_HOME)" echo "Detected default user: $DEFAULT_USER (home: $DEFAULT_HOME)"
# Install pip3 if not already installed # Packages (nodejs, npm, lrzsz) installed by 00-apt-setup stage
if ! command -v pip3 &> /dev/null; then # pip3 is available from stage2 Python installation
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
# Install Python packages from requirements.txt # Install Python packages from requirements.txt
pip3 install --break-system-packages -r /tmp/dangerous-pi-files/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 # Add user to netdev group for network management
usermod -a -G netdev $DEFAULT_USER usermod -a -G netdev $DEFAULT_USER
# Build frontend (Remix SSR) # Frontend setup (Remix SSR)
echo "Building frontend..." # 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 cd /opt/dangerous-pi/app/frontend
npm ci --production=false # Install dev deps for build if [ -d "build/server" ]; then
npm run build echo "Using pre-built frontend from host..."
# Remove dev dependencies and node_modules bloat after build # Remove build-time-only packages to save ~100MB of image space
rm -rf node_modules/.cache rm -rf node_modules/.cache \
npm prune --production 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 cd /opt/dangerous-pi
# Install frontend systemd service # Install frontend systemd service

View File

@@ -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/data"
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/logs" 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" echo "Files prepared successfully"

Some files were not shown because too many files have changed in this diff Show More