From a9acdb85ce7fa65e2161d8f203da1b1095b77559 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 4 Mar 2026 12:01:01 -0800 Subject: [PATCH] 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 --- .github/workflows/base-image.yml | 77 ++ .github/workflows/build-release.yml | 152 ++ .plan | 282 ++++ TEST_RESULTS_2026-01-23.md | 327 +++++ TEST_RESULTS_2026-03-03.md | 189 +++ TEST_RESULTS_2026-03-03_v2.md | 208 +++ TEST_RESULTS_2026-03-03_v3.md | 195 +++ TEST_RESULTS_2026-03-03_v4.md | 195 +++ app/backend/api/system.py | 240 +++- app/backend/api/updates.py | 226 +-- app/backend/config.py | 3 + app/backend/main.py | 11 + app/backend/managers/ble_manager.py | 31 +- app/backend/managers/os_update_manager.py | 312 +++++ app/backend/managers/plugin_manager.py | 130 ++ app/backend/managers/update_manager.py | 990 +++++++++++--- app/backend/services/update_service.py | 168 ++- app/frontend/app/components/ConnectDialog.tsx | 2 +- .../app/components/DeviceSelector.tsx | 12 +- app/frontend/app/components/LoginPrompt.tsx | 2 +- app/frontend/app/components/QRScanner.tsx | 4 +- app/frontend/app/lib/ThemeContext.tsx | 155 +++ app/frontend/app/root.tsx | 77 +- app/frontend/app/routes/_index.tsx | 2 +- app/frontend/app/routes/settings.tsx | 65 +- app/frontend/app/routes/updates.tsx | 1218 +++++++++++++---- .../frontend/app/styles/base.css | 209 ++- app/frontend/package-lock.json | 21 + app/frontend/package.json | 1 + .../fonts/Tektur-VariableFont_wdth,wght.ttf | Bin 0 -> 162656 bytes app/frontend/themes/classic/theme.json | 8 + app/frontend/themes/classic/tokens.css | 5 + app/frontend/themes/dt/theme.json | 8 + app/frontend/themes/dt/tokens.css | 11 + app/plugins/supra_theme/main.py | 68 + app/plugins/supra_theme/plugin.json | 10 + app/plugins/supra_theme/static/tokens.css | 232 ++++ build-image.sh | 194 ++- ci/build-backend.sh | 42 + ci/build-frontend.sh | 51 + ci/build-pm3.sh | 169 +++ ci/build-theme.sh | 100 ++ ci/generate-manifest.py | 124 ++ docs/COMPONENT_UPDATE_SYSTEM.md | 339 +++++ docs/ON_DEVICE_TESTING.md | 330 +++++ pi-gen/config | 2 +- .../stageDangerousPi/00-apt-setup/00-packages | 17 + .../01-Wireless-AP/00-run-chroot.sh | 8 +- .../02-pm3-install/00-run-chroot-compile.sh} | 38 +- .../02-pm3-install/00-run-chroot.sh | 282 ++++ .../stageDangerousPi/02-pm3-install/00-run.sh | 93 ++ .../hf-booster-detection.patch | 0 .../02-pm3-install}/led-pwm-control.patch | 10 +- .../02-pm3-install/pm3-version.conf | 5 + .../{02-ttyd => 03-ttyd}/00-run-chroot.sh | 0 .../00-run-chroot.sh | 40 +- .../00-run.sh | 11 + .../01-run-chroot.sh | 0 .../README.md | 0 .../files/app/__init__.py | 0 .../files/app/backend/api/__init__.py | 0 .../files/app/backend/api/auth.py | 0 .../files/app/backend/api/health.py | 0 .../files/app/backend/api/plugins.py | 0 .../files/app/backend/api/pm3.py | 0 .../files/app/backend/api/system.py | 0 .../files/app/backend/api/updates.py | 0 .../files/app/backend/api/wifi.py | 0 .../files/app/backend/ble/__init__.py | 0 .../files/app/backend/ble/bluez_adapter.py | 0 .../files/app/backend/ble/characteristics.py | 0 .../files/app/backend/ble/gatt_server.py | 0 .../files/app/backend/config.py | 0 .../files/app/backend/main.py | 0 .../files/app/backend/managers/__init__.py | 0 .../files/app/backend/managers/ble_manager.py | 0 .../app/backend/managers/plugin_manager.py | 0 .../backend/managers/pm3_device_manager.py | 0 .../app/backend/managers/session_manager.py | 0 .../app/backend/managers/update_manager.py | 0 .../backend/managers/ups_drivers/__init__.py | 0 .../app/backend/managers/ups_drivers/base.py | 0 .../managers/ups_drivers/i2c_driver.py | 0 .../managers/ups_drivers/pisugar_driver.py | 0 .../ups_drivers/pisugar_i2c_driver.py | 0 .../files/app/backend/managers/ups_manager.py | 0 .../app/backend/managers/wifi_manager.py | 0 .../files/app/backend/models/__init__.py | 0 .../files/app/backend/models/database.py | 0 .../files/app/backend/services/__init__.py | 0 .../files/app/backend/services/container.py | 0 .../files/app/backend/services/pm3_service.py | 0 .../app/backend/services/system_service.py | 0 .../app/backend/services/update_service.py | 0 .../app/backend/services/wifi_service.py | 0 .../files/app/backend/websocket/__init__.py | 0 .../files/app/backend/websocket/manager.py | 0 .../app/backend/websocket/notifications.py | 0 .../files/app/backend/websocket/routes.py | 0 .../files/app/backend/workers/__init__.py | 0 .../files/app/backend/workers/pm3_worker.py | 0 .../files/app/frontend/README.md | 0 .../frontend/app/components/ConnectDialog.tsx | 0 .../app/components/DeviceSelector.tsx | 0 .../frontend/app/components/HeaderWidgets.tsx | 0 .../frontend/app/components/PowerWidget.tsx | 0 .../app/frontend/app/components/QRScanner.tsx | 0 .../files/app/frontend/app/entry.client.tsx | 0 .../files/app/frontend/app/entry.server.tsx | 0 .../app/frontend/app/hooks/useWebSocket.ts | 0 .../files/app/frontend/app/root.tsx | 0 .../files/app/frontend/app/routes/_index.tsx | 0 .../app/frontend/app/routes/commands.tsx | 0 .../files/app/frontend/app/routes/logs.tsx | 0 .../app/frontend/app/routes/settings.tsx | 0 .../files/app/frontend/app/routes/updates.tsx | 0 .../files/app}/frontend/app/styles.css | 0 .../build/client/assets/_index-CIJCcFpO.js | 0 .../build/client/assets/commands-DSGkGbDk.js | 0 .../client/assets/components-DjjSZ9bp.js | 0 .../client/assets/entry.client-CkrwxAV0.js | 0 .../build/client/assets/logs-C_6-dKR0.js | 0 .../build/client/assets/manifest-cbba6713.js | 0 .../build/client/assets/root-f_Ye4G9l.js | 0 .../build/client/assets/settings-BHbKcVlC.js | 0 .../build/client/assets/styles-D7Zjtr3a.css | 0 .../build/client/assets/updates-CNJu1SrW.js | 0 .../files/app/frontend/build/server/index.js | 0 .../files/app/frontend/package-lock.json | 0 .../files/app/frontend/package.json | 0 .../files/app/frontend/tsconfig.json | 0 .../files/app/frontend/vite.config.ts | 0 .../files/app/plugins/hello_world/main.py | 0 .../files/app/plugins/hello_world/plugin.json | 0 .../polkit-1/rules.d/50-dangerous-pi.rules | 0 .../files/requirements.txt | 0 .../files/scripts/apply-cpu-cores.sh | 0 .../files/scripts/audit-build-scripts.sh | 0 .../files/scripts/build-status.sh | 0 .../files/scripts/install-udev-rules.sh | 0 .../files/scripts/preflight-check.sh | 0 .../files/scripts/resolve-port-conflict.sh | 0 .../files/scripts/serial_transfer.py | 0 .../files/systemd/README.md | 0 .../systemd/dangerous-pi-cpu-cores.service | 0 .../systemd/dangerous-pi-frontend.service | 0 .../files/systemd/dangerous-pi.env.example | 0 .../files/systemd/dangerous-pi.service | 0 .../files/systemd/install-service.sh | 0 .../files/systemd/uninstall-service.sh | 0 .../00-run-chroot.sh | 8 +- .../{04-pisugar => 05-pisugar}/README.md | 0 .../00-run-chroot.sh | 0 .../07-os-updates/00-packages | 1 + .../07-os-updates/00-run-chroot.sh | 55 + .../07-os-updates/01-run-chroot.sh | 36 + pi-gen/stagePM3/01-proxmark3/00-run.sh | 18 - pi-gen/stagePM3/SKIP_NOOBS | 1 - pi-gen/stagePM3/prerun.sh | 5 - scripts/audit-build-scripts.sh | 6 +- scripts/preflight-check.sh | 8 +- systemd/dangerous-pi.env.example | 5 + test_hardware_2026-03-03.py | 1201 ++++++++++++++++ 163 files changed, 8124 insertions(+), 921 deletions(-) create mode 100644 .github/workflows/base-image.yml create mode 100644 .github/workflows/build-release.yml create mode 100644 .plan create mode 100644 TEST_RESULTS_2026-01-23.md create mode 100644 TEST_RESULTS_2026-03-03.md create mode 100644 TEST_RESULTS_2026-03-03_v2.md create mode 100644 TEST_RESULTS_2026-03-03_v3.md create mode 100644 TEST_RESULTS_2026-03-03_v4.md create mode 100644 app/backend/managers/os_update_manager.py create mode 100644 app/frontend/app/lib/ThemeContext.tsx rename pi-gen/stageDangerousPi/03-dangerous-pi/files/app/frontend/app/styles.css => app/frontend/app/styles/base.css (74%) create mode 100644 app/frontend/public/fonts/Tektur-VariableFont_wdth,wght.ttf create mode 100644 app/frontend/themes/classic/theme.json create mode 100644 app/frontend/themes/classic/tokens.css create mode 100644 app/frontend/themes/dt/theme.json create mode 100644 app/frontend/themes/dt/tokens.css create mode 100644 app/plugins/supra_theme/main.py create mode 100644 app/plugins/supra_theme/plugin.json create mode 100644 app/plugins/supra_theme/static/tokens.css create mode 100755 ci/build-backend.sh create mode 100755 ci/build-frontend.sh create mode 100755 ci/build-pm3.sh create mode 100755 ci/build-theme.sh create mode 100755 ci/generate-manifest.py create mode 100644 docs/COMPONENT_UPDATE_SYSTEM.md create mode 100644 docs/ON_DEVICE_TESTING.md create mode 100644 pi-gen/stageDangerousPi/00-apt-setup/00-packages rename pi-gen/{stagePM3/01-proxmark3/00-run-chroot.sh => stageDangerousPi/02-pm3-install/00-run-chroot-compile.sh} (89%) create mode 100644 pi-gen/stageDangerousPi/02-pm3-install/00-run-chroot.sh create mode 100755 pi-gen/stageDangerousPi/02-pm3-install/00-run.sh rename pi-gen/{stagePM3/01-proxmark3 => stageDangerousPi/02-pm3-install}/hf-booster-detection.patch (100%) rename pi-gen/{stagePM3/01-proxmark3 => stageDangerousPi/02-pm3-install}/led-pwm-control.patch (98%) create mode 100644 pi-gen/stageDangerousPi/02-pm3-install/pm3-version.conf rename pi-gen/stageDangerousPi/{02-ttyd => 03-ttyd}/00-run-chroot.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/00-run-chroot.sh (82%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/00-run.sh (59%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/01-run-chroot.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/README.md (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/auth.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/health.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/plugins.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/pm3.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/system.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/updates.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/api/wifi.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/ble/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/ble/bluez_adapter.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/ble/characteristics.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/ble/gatt_server.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/config.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/main.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ble_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/plugin_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/pm3_device_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/session_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/update_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ups_drivers/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ups_drivers/base.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ups_drivers/i2c_driver.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ups_drivers/pisugar_driver.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ups_drivers/pisugar_i2c_driver.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/ups_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/managers/wifi_manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/models/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/models/database.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/services/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/services/container.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/services/pm3_service.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/services/system_service.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/services/update_service.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/services/wifi_service.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/websocket/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/websocket/manager.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/websocket/notifications.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/websocket/routes.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/workers/__init__.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/backend/workers/pm3_worker.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/README.md (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/components/ConnectDialog.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/components/DeviceSelector.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/components/HeaderWidgets.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/components/PowerWidget.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/components/QRScanner.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/entry.client.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/entry.server.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/hooks/useWebSocket.ts (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/root.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/routes/_index.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/routes/commands.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/routes/logs.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/routes/settings.tsx (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/app/routes/updates.tsx (100%) rename {app => pi-gen/stageDangerousPi/04-dangerous-pi/files/app}/frontend/app/styles.css (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/_index-CIJCcFpO.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/commands-DSGkGbDk.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/components-DjjSZ9bp.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/entry.client-CkrwxAV0.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/logs-C_6-dKR0.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/manifest-cbba6713.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/root-f_Ye4G9l.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/settings-BHbKcVlC.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/styles-D7Zjtr3a.css (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/client/assets/updates-CNJu1SrW.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/build/server/index.js (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/package-lock.json (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/package.json (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/tsconfig.json (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/frontend/vite.config.ts (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/plugins/hello_world/main.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/app/plugins/hello_world/plugin.json (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/etc/polkit-1/rules.d/50-dangerous-pi.rules (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/requirements.txt (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/apply-cpu-cores.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/audit-build-scripts.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/build-status.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/install-udev-rules.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/preflight-check.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/resolve-port-conflict.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/scripts/serial_transfer.py (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/README.md (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/dangerous-pi-cpu-cores.service (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/dangerous-pi-frontend.service (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/dangerous-pi.env.example (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/dangerous-pi.service (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/install-service.sh (100%) rename pi-gen/stageDangerousPi/{03-dangerous-pi => 04-dangerous-pi}/files/systemd/uninstall-service.sh (100%) rename pi-gen/stageDangerousPi/{04-pisugar => 05-pisugar}/00-run-chroot.sh (90%) rename pi-gen/stageDangerousPi/{04-pisugar => 05-pisugar}/README.md (100%) rename pi-gen/stageDangerousPi/{05-https-support => 06-https-support}/00-run-chroot.sh (100%) create mode 100644 pi-gen/stageDangerousPi/07-os-updates/00-packages create mode 100755 pi-gen/stageDangerousPi/07-os-updates/00-run-chroot.sh create mode 100755 pi-gen/stageDangerousPi/07-os-updates/01-run-chroot.sh delete mode 100755 pi-gen/stagePM3/01-proxmark3/00-run.sh delete mode 100644 pi-gen/stagePM3/SKIP_NOOBS delete mode 100755 pi-gen/stagePM3/prerun.sh create mode 100644 test_hardware_2026-03-03.py diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml new file mode 100644 index 0000000..586a771 --- /dev/null +++ b/.github/workflows/base-image.yml @@ -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 diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..ec90d5e --- /dev/null +++ b/.github/workflows/build-release.yml @@ -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 diff --git a/.plan b/.plan new file mode 100644 index 0000000..a566a1a --- /dev/null +++ b/.plan @@ -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 diff --git a/TEST_RESULTS_2026-01-23.md b/TEST_RESULTS_2026-01-23.md new file mode 100644 index 0000000..3a636e7 --- /dev/null +++ b/TEST_RESULTS_2026-01-23.md @@ -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* diff --git a/TEST_RESULTS_2026-03-03.md b/TEST_RESULTS_2026-03-03.md new file mode 100644 index 0000000..62c192f --- /dev/null +++ b/TEST_RESULTS_2026-03-03.md @@ -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* \ No newline at end of file diff --git a/TEST_RESULTS_2026-03-03_v2.md b/TEST_RESULTS_2026-03-03_v2.md new file mode 100644 index 0000000..5a6d1d5 --- /dev/null +++ b/TEST_RESULTS_2026-03-03_v2.md @@ -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* \ No newline at end of file diff --git a/TEST_RESULTS_2026-03-03_v3.md b/TEST_RESULTS_2026-03-03_v3.md new file mode 100644 index 0000000..4b43134 --- /dev/null +++ b/TEST_RESULTS_2026-03-03_v3.md @@ -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* \ No newline at end of file diff --git a/TEST_RESULTS_2026-03-03_v4.md b/TEST_RESULTS_2026-03-03_v4.md new file mode 100644 index 0000000..2770e73 --- /dev/null +++ b/TEST_RESULTS_2026-03-03_v4.md @@ -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* \ No newline at end of file diff --git a/app/backend/api/system.py b/app/backend/api/system.py index d670fb0..d65a861 100644 --- a/app/backend/api/system.py +++ b/app/backend/api/system.py @@ -3,13 +3,17 @@ Refactored to use services for business logic. Session management uses PM3Service, system operations use SystemService. """ +import json +from pathlib import Path from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from typing import Optional, Dict +from typing import Optional, Dict, List +from .. import config from ..services.container import container from ..managers.ups_manager import get_ups_manager from ..managers.ble_manager import get_ble_manager +from ..managers.os_update_manager import get_os_update_manager router = APIRouter() @@ -676,14 +680,14 @@ async def get_ssl_info(): ["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"], capture_output=True, text=True, timeout=5 ) - if result.returncode == 0 and "subjectAltName" in result.stdout: + if result.returncode == 0 and ("subjectAltName" in result.stdout or "Subject Alternative Name" in result.stdout): # Parse SANs from output like "DNS:localhost, IP:192.168.4.1" san_line = result.stdout.strip() for line in san_line.split("\n"): - if "DNS:" in line or "IP:" in line: + if "DNS:" in line or "IP" in line: # Split by comma and clean up sans = [s.strip() for s in line.split(",")] - cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))] + cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP"))] break # Get SHA256 fingerprint @@ -738,10 +742,19 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest): import subprocess import os - script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh" + # Check multiple possible locations for the script + script_candidates = [ + "/opt/dangerous-pi/scripts/generate-ssl-cert.sh", + os.path.expanduser("~/dangerous-pi/scripts/generate-ssl-cert.sh"), + os.path.join(os.path.dirname(__file__), "../../../scripts/generate-ssl-cert.sh"), + ] + script_path = None + for candidate in script_candidates: + if os.path.exists(candidate): + script_path = candidate + break - # Check if script exists - if not os.path.exists(script_path): + if not script_path: raise HTTPException( status_code=500, detail="SSL certificate generation script not found" @@ -975,4 +988,217 @@ async def clear_dismissed_widgets(): plugin_manager = get_plugin_manager() plugin_manager.clear_dismissed() + +# --------------------------------------------------------------------------- +# OS Updates API +# --------------------------------------------------------------------------- + + +@router.get("/os/info") +async def get_os_info(): + """Get OS-level system information. + + Returns Debian version, kernel, architecture, uptime, hostname, + last apt update timestamp, auto-update setting, and reboot-required status. + """ + manager = get_os_update_manager() + return await manager.get_os_info() + + +@router.get("/os/updates") +async def get_os_updates(refresh: bool = False): + """Get available OS package updates. + + Returns a list of upgradable packages with current and available versions. + Results are cached for 1 hour unless refresh=True. + """ + manager = get_os_update_manager() + packages = await manager.check_available_updates(force_refresh=refresh) + return { + "count": len(packages), + "packages": packages, + "upgrading": manager.is_upgrading, + } + + +class OsUpgradeRequest(BaseModel): + """Request to trigger an OS package upgrade.""" + security_only: bool = False + + +@router.post("/os/update") +async def run_os_update(request: OsUpgradeRequest): + """Trigger an OS package upgrade. + + Runs `apt-get upgrade -y` (or unattended-upgrade --verbose for security-only). + Only one upgrade can run at a time. + + Args: + security_only: If True, only install security updates + """ + manager = get_os_update_manager() + result = await manager.run_upgrade(security_only=request.security_only) + + if not result.get("success"): + raise HTTPException(status_code=409 if "already in progress" in result.get("error", "") else 500, + detail=result.get("error", "Upgrade failed")) + + return result + + +class AutoUpdatesRequest(BaseModel): + """Request to toggle automatic security updates.""" + enabled: bool + + +@router.post("/os/auto-updates") +async def toggle_auto_updates(request: AutoUpdatesRequest): + """Toggle automatic security updates. + + Writes AUTO_SECURITY_UPDATES to the .env file and restarts the + systemd timer that controls unattended-upgrades. + + Args: + enabled: True to enable, False to disable automatic security updates + """ + manager = get_os_update_manager() + result = await manager.toggle_auto_updates(request.enabled) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to toggle auto-updates")) + + return result + + +# --------------------------------------------------------------------------- +# Theme registry +# --------------------------------------------------------------------------- + +class ThemeDefinitionResponse(BaseModel): + """A single theme definition.""" + id: str + name: str + description: str = "" + supportsModes: List[str] = ["dark", "light", "auto"] + defaultMode: str = "dark" + author: str = "" + css_url: str = "" + source: str = "builtin" # "builtin" or "plugin" + + +class ThemeRegistryResponse(BaseModel): + """Response for GET /api/system/themes.""" + themes: List[ThemeDefinitionResponse] + + +def _scan_theme_dirs() -> List[ThemeDefinitionResponse]: + """Scan themes directories for installed theme packages.""" + themes_found: List[ThemeDefinitionResponse] = [] + + # Search paths: dev + production + themes_dirs = [ + Path(__file__).parent.parent.parent / "frontend" / "themes", # dev + Path("/opt/dangerous-pi/app/frontend/themes"), # prod + ] + + seen_ids: set = set() + for themes_dir in themes_dirs: + if not themes_dir.is_dir(): + continue + for entry in sorted(themes_dir.iterdir()): + if not entry.is_dir() or entry.name.startswith("."): + continue + theme_json = entry / "theme.json" + if not theme_json.exists(): + continue + try: + meta = json.loads(theme_json.read_text()) + theme_id = meta.get("id", entry.name) + if theme_id in seen_ids: + continue + seen_ids.add(theme_id) + themes_found.append(ThemeDefinitionResponse( + id=theme_id, + name=meta.get("name", theme_id), + description=meta.get("description", ""), + supportsModes=meta.get("supportsModes", ["dark", "light", "auto"]), + defaultMode=meta.get("defaultMode", "dark"), + author=meta.get("author", ""), + css_url=f"/themes/{theme_id}/tokens.css", + source="builtin", + )) + except (json.JSONDecodeError, OSError) as exc: + print(f"Warning: bad theme.json in {entry}: {exc}") + + return themes_found + + +def _scan_plugin_themes() -> List[ThemeDefinitionResponse]: + """Collect themes registered by plugins via the theme_register hook.""" + from ..managers.plugin_manager import get_plugin_manager + pm = get_plugin_manager() + + themes: List[ThemeDefinitionResponse] = [] + if "theme_register" not in pm._hooks: + return themes + + import asyncio + results = [] + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + # Hooks are called synchronously here since they are simple data returns + for callback in pm._hooks.get("theme_register", []): + try: + if asyncio.iscoroutinefunction(callback): + # Schedule in running loop if available + if loop: + import concurrent.futures + # Can't await in sync context; skip async hooks + continue + else: + continue + result = callback() + if result: + results.append(result) + except Exception as exc: + print(f"Warning: theme_register hook error: {exc}") + + for r in results: + themes.append(ThemeDefinitionResponse( + id=r.get("id", "unknown"), + name=r.get("name", "Unknown Theme"), + description=r.get("description", ""), + supportsModes=r.get("supportsModes", ["dark", "light", "auto"]), + defaultMode=r.get("defaultMode", "dark"), + author=r.get("author", ""), + css_url=r.get("css_url", f"/themes/{r.get('id', 'unknown')}/tokens.css"), + source="plugin", + )) + + return themes + + +@router.get("/themes", response_model=ThemeRegistryResponse) +async def get_available_themes(): + """Get available themes from disk and plugin registry. + + Scans the themes/ directory for installed theme packages + and collects themes registered by plugins via the theme_register hook. + """ + builtin = _scan_theme_dirs() + plugin_themes = _scan_plugin_themes() + + # Merge, preferring builtin for duplicate IDs + seen = {t.id for t in builtin} + all_themes = list(builtin) + for pt in plugin_themes: + if pt.id not in seen: + all_themes.append(pt) + seen.add(pt.id) + + return ThemeRegistryResponse(themes=all_themes) + return {"success": True, "message": "Dismissed widgets cleared"} diff --git a/app/backend/api/updates.py b/app/backend/api/updates.py index ec5ed1c..a0d3783 100644 --- a/app/backend/api/updates.py +++ b/app/backend/api/updates.py @@ -2,9 +2,11 @@ Refactored to use UpdateService for all business logic. Endpoints are now thin adapters that convert HTTP requests/responses. + +Supports both legacy whole-system operations and per-component operations. """ -from typing import Optional -from fastapi import APIRouter, HTTPException +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Path from pydantic import BaseModel from ..services.container import container @@ -14,6 +16,31 @@ from ..managers.ble_manager import get_ble_manager, NotificationType router = APIRouter() +# --------------------------------------------------------------------------- +# Response / request models +# --------------------------------------------------------------------------- + +class ComponentUpdateInfo(BaseModel): + """Per-component update information.""" + component_id: str + current_version: Optional[str] = None + available_version: str + changelog: str = "" + download_size: Optional[int] = None + compatible: bool = True + incompatible_reason: Optional[str] = None + + +class PluginUpdateInfo(BaseModel): + """Per-plugin update information.""" + plugin_id: str + current_version: str + available_version: str + changelog: str = "" + download_size: Optional[int] = None + source_repo: str = "" + + class UpdateCheckResponse(BaseModel): """Response model for update check.""" update_available: bool @@ -24,6 +51,8 @@ class UpdateCheckResponse(BaseModel): is_prerelease: bool = False download_size: Optional[int] = None message: Optional[str] = None + components: List[ComponentUpdateInfo] = [] + plugins: List[PluginUpdateInfo] = [] class UpdateProgressResponse(BaseModel): @@ -34,6 +63,8 @@ class UpdateProgressResponse(BaseModel): download_progress: float = 0.0 error_message: Optional[str] = None last_check: Optional[str] = None + active_component: Optional[str] = None + components: List[dict] = [] class ReleaseNotesRequest(BaseModel): @@ -41,15 +72,17 @@ class ReleaseNotesRequest(BaseModel): version: Optional[str] = None +class ComponentDownloadRequest(BaseModel): + """Optional request body for selective download.""" + components: Optional[List[str]] = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + def _service_error_to_http_status(error_code: str) -> int: - """Map service error codes to HTTP status codes. - - Args: - error_code: Service error code - - Returns: - HTTP status code - """ + """Map service error codes to HTTP status codes.""" codes = { "update_check_error": 500, "no_update_available": 400, @@ -62,35 +95,44 @@ def _service_error_to_http_status(error_code: str) -> int: "release_notes_error": 500, "check_download_error": 500, "full_update_error": 500, + "manifest_error": 500, + "component_not_available": 400, + "component_download_error": 500, + "component_install_error": 500, + "rollback_error": 500, } return codes.get(error_code, 500) -@router.get("/check", response_model=UpdateCheckResponse) -async def check_for_updates(): - """Check for available updates. - - Uses UpdateService for business logic. - """ - result = await container.update_service.check_for_updates() - +def _raise_on_error(result): + """Raise HTTPException if result indicates failure.""" if not result.success: raise HTTPException( status_code=_service_error_to_http_status(result.error.code), - detail=result.error.message + detail=result.error.message, ) - # Send BLE notification if update is available + +# --------------------------------------------------------------------------- +# Legacy endpoints (backward-compatible) +# --------------------------------------------------------------------------- + +@router.get("/check", response_model=UpdateCheckResponse) +async def check_for_updates(): + """Check for available updates (components + plugins).""" + result = await container.update_service.check_for_updates() + _raise_on_error(result) + + # BLE notification if result.data.get("update_available"): try: ble_manager = get_ble_manager() await ble_manager.send_notification( NotificationType.UPDATE_AVAILABLE, f"Update available: v{result.data['latest_version']}", - {"version": result.data["latest_version"]} + {"version": result.data["latest_version"]}, ) except Exception: - # BLE notification failure shouldn't affect the response pass return UpdateCheckResponse(**result.data) @@ -98,108 +140,126 @@ async def check_for_updates(): @router.get("/progress", response_model=UpdateProgressResponse) async def get_update_progress(): - """Get current update progress. - - Uses UpdateService for business logic. - """ + """Get current update progress.""" result = await container.update_service.get_progress() - - if not result.success: - raise HTTPException( - status_code=_service_error_to_http_status(result.error.code), - detail=result.error.message - ) - + _raise_on_error(result) return UpdateProgressResponse(**result.data) @router.post("/download") -async def download_update(): - """Download the available update. +async def download_update(body: Optional[ComponentDownloadRequest] = None): + """Download available updates. - Uses UpdateService for business logic. + Without a body, downloads all available components (legacy behavior). + With ``{"components": ["frontend"]}``, downloads only the specified ones. """ + if body and body.components: + results = {} + for comp_id in body.components: + r = await container.update_service.download_component(comp_id) + _raise_on_error(r) + results[comp_id] = r.data + return {"message": "Components downloaded", "components": results} + result = await container.update_service.download_update() - - if not result.success: - raise HTTPException( - status_code=_service_error_to_http_status(result.error.code), - detail=result.error.message - ) - + _raise_on_error(result) return {"message": result.data["message"]} @router.post("/install") -async def install_update(): - """Install the downloaded update. +async def install_update(body: Optional[ComponentDownloadRequest] = None): + """Install downloaded updates. - Uses UpdateService for business logic. + Without a body, installs all downloaded components (legacy behavior). + With ``{"components": ["frontend"]}``, installs only the specified ones. """ + if body and body.components: + results = {} + for comp_id in body.components: + r = await container.update_service.install_component(comp_id) + _raise_on_error(r) + results[comp_id] = r.data + return {"message": "Components installed", "components": results} + result = await container.update_service.install_update() + _raise_on_error(result) - if not result.success: - raise HTTPException( - status_code=_service_error_to_http_status(result.error.code), - detail=result.error.message - ) - - # Send BLE notification + # BLE notification try: ble_manager = get_ble_manager() await ble_manager.send_notification( NotificationType.UPDATE_COMPLETE, "Update installed successfully", - {"restart_required": True} + {"restart_required": True}, ) except Exception: - # BLE notification failure shouldn't affect the response pass return { "message": result.data["message"], - "restart_required": result.data.get("requires_restart", True) + "restart_required": result.data.get("requires_restart", True), } @router.post("/release-notes", response_model=dict) async def get_release_notes(request: ReleaseNotesRequest): - """Get release notes for a specific version. - - Uses UpdateService for business logic. - - Args: - request: Version to get notes for (latest if not specified) - """ + """Get release notes for a specific version.""" result = await container.update_service.get_release_notes(request.version) - - if not result.success: - raise HTTPException( - status_code=_service_error_to_http_status(result.error.code), - detail=result.error.message - ) - + _raise_on_error(result) return { "version": result.data["version"], - "notes": result.data["release_notes"] + "notes": result.data["release_notes"], } @router.get("/current-version") async def get_current_version(): - """Get current system version. - - Uses UpdateService for business logic. - """ + """Get current system version.""" result = await container.update_service.get_progress() - - if not result.success: - raise HTTPException( - status_code=_service_error_to_http_status(result.error.code), - detail=result.error.message - ) - + _raise_on_error(result) return { "version": result.data["current_version"], - "last_check": result.data["last_check"] + "last_check": result.data["last_check"], } + + +# --------------------------------------------------------------------------- +# Component-level endpoints +# --------------------------------------------------------------------------- + +@router.get("/components") +async def get_installed_components(): + """Get installed component manifest.""" + result = await container.update_service.get_installed_components() + _raise_on_error(result) + return result.data + + +@router.post("/components/{component_id}/download") +async def download_component( + component_id: str = Path(description="Component ID (pm3, frontend, backend, theme)"), +): + """Download a single component update.""" + result = await container.update_service.download_component(component_id) + _raise_on_error(result) + return result.data + + +@router.post("/components/{component_id}/install") +async def install_component( + component_id: str = Path(description="Component ID"), +): + """Install a single downloaded component.""" + result = await container.update_service.install_component(component_id) + _raise_on_error(result) + return result.data + + +@router.post("/components/{component_id}/rollback") +async def rollback_component( + component_id: str = Path(description="Component ID"), +): + """Rollback a component to its backup.""" + result = await container.update_service.rollback_component(component_id) + _raise_on_error(result) + return result.data diff --git a/app/backend/config.py b/app/backend/config.py index 03891c1..ae63380 100644 --- a/app/backend/config.py +++ b/app/backend/config.py @@ -30,6 +30,9 @@ VERSION = os.getenv("VERSION", "0.1.0") # Update settings GITHUB_REPO = os.getenv("GITHUB_REPO", "dangerous-tacos/dangerous-pi") UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour +COMPONENT_MANIFEST_PATH = os.getenv( + "COMPONENT_MANIFEST_PATH", "/opt/dangerous-pi/component-manifest.json" +) # Wi-Fi settings WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0") diff --git a/app/backend/main.py b/app/backend/main.py index 3a44ce4..f664059 100644 --- a/app/backend/main.py +++ b/app/backend/main.py @@ -204,6 +204,17 @@ app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], depe app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(ws_router, prefix="/ws", tags=["websocket"]) +# Serve theme token CSS from themes/ directory +theme_paths = [ + Path(__file__).parent.parent / "frontend" / "themes", # Development + Path("/opt/dangerous-pi/app/frontend/themes"), # Production +] +for tp in theme_paths: + if tp.exists() and tp.is_dir(): + app.mount("/themes", StaticFiles(directory=str(tp)), name="themes") + print(f"🎨 Serving themes from: {tp}") + break + # Serve frontend static files if build directory exists # In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build # Priority: 1) check relative to working directory, 2) check absolute production path diff --git a/app/backend/managers/ble_manager.py b/app/backend/managers/ble_manager.py index 936e830..e1ca0c0 100644 --- a/app/backend/managers/ble_manager.py +++ b/app/backend/managers/ble_manager.py @@ -210,7 +210,7 @@ class BLEManager: } async def _check_bluetooth_adapter(self) -> bool: - """Check if Bluetooth adapter is available. + """Check if Bluetooth adapter is available, unblocking and powering on if needed. Returns: True if adapter is available, False otherwise @@ -224,8 +224,20 @@ class BLEManager: ) stdout, stderr = await process.communicate() - # If we get output with "Controller", we have an adapter - return b"Controller" in stdout + if b"Controller" not in stdout: + return False + + # Unblock Bluetooth if soft-blocked by rfkill + await self._run_cmd("rfkill", "unblock", "bluetooth") + + # Power on the adapter via bluetoothctl + out = await self._run_cmd("bluetoothctl", "power", "on") + if b"succeeded" in out.lower() or b"yes" in out.lower(): + logger.info("Bluetooth adapter powered on") + else: + logger.warning("Bluetooth power on response: %s", out.decode(errors='replace').strip()) + + return True except FileNotFoundError: logger.warning("bluetoothctl not found - BlueZ not installed") @@ -234,6 +246,19 @@ class BLEManager: logger.error("Error checking Bluetooth adapter: %s", e) return False + async def _run_cmd(self, *args: str) -> bytes: + """Run a command and return stdout.""" + try: + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await process.communicate() + return stdout + except FileNotFoundError: + return b"" + async def _set_device_name(self, name: str): """Set the Bluetooth device name. diff --git a/app/backend/managers/os_update_manager.py b/app/backend/managers/os_update_manager.py new file mode 100644 index 0000000..ab6ee43 --- /dev/null +++ b/app/backend/managers/os_update_manager.py @@ -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 diff --git a/app/backend/managers/plugin_manager.py b/app/backend/managers/plugin_manager.py index 97525cb..1e4fd59 100644 --- a/app/backend/managers/plugin_manager.py +++ b/app/backend/managers/plugin_manager.py @@ -8,6 +8,7 @@ import asyncio import importlib.util import inspect import json +import re import time from dataclasses import dataclass, asdict, field from datetime import datetime, timezone @@ -16,6 +17,8 @@ from pathlib import Path from typing import Optional, Dict, Any, List, Callable, Set import sys +import aiohttp + from .. import config @@ -400,6 +403,18 @@ class PluginBase: ) +@dataclass +class PluginUpdateInfo: + """Information about an available plugin update.""" + plugin_id: str + current_version: str + available_version: str + changelog: str = "" + download_url: str = "" + download_size: Optional[int] = None + source_repo: str = "" + + class PluginManager: """Manages plugin loading, enabling, lifecycle, and header widgets.""" @@ -790,6 +805,121 @@ class PluginManager: del self._header_widgets[widget_id] print(f"Expired widget removed: {widget_id}") + # ------------------------------------------------------------------------- + # Plugin Update Checking + # ------------------------------------------------------------------------- + + @staticmethod + def _extract_github_repo(homepage: Optional[str]) -> Optional[str]: + """Extract 'owner/repo' from a GitHub URL. + + Args: + homepage: Plugin homepage URL + + Returns: + 'owner/repo' string or None + """ + if not homepage: + return None + m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", homepage) + return m.group(1) if m else None + + async def check_plugin_updates(self) -> List[PluginUpdateInfo]: + """Check all installed plugins for available updates. + + Queries each plugin's GitHub repo (derived from homepage) for + newer releases. Skips plugins without a GitHub homepage. + + Returns: + List of PluginUpdateInfo for plugins with updates available + """ + updates: List[PluginUpdateInfo] = [] + + plugins_to_check = [] + for plugin_id, info in self._plugins.items(): + repo = self._extract_github_repo(info.metadata.homepage) + if repo: + plugins_to_check.append((plugin_id, info, repo)) + + if not plugins_to_check: + return updates + + async with aiohttp.ClientSession() as session: + for plugin_id, info, repo in plugins_to_check: + try: + update = await self._check_single_plugin_update( + session, plugin_id, info, repo + ) + if update: + updates.append(update) + except Exception as e: + print(f"Error checking updates for plugin {plugin_id}: {e}") + + return updates + + async def _check_single_plugin_update( + self, + session: aiohttp.ClientSession, + plugin_id: str, + info: PluginInfo, + repo: str, + ) -> Optional[PluginUpdateInfo]: + """Check a single plugin for available update. + + Args: + session: aiohttp session + plugin_id: Plugin identifier + info: Current plugin info + repo: GitHub 'owner/repo' string + + Returns: + PluginUpdateInfo if update available, None otherwise + """ + url = f"https://api.github.com/repos/{repo}/releases/latest" + async with session.get(url) as resp: + if resp.status != 200: + return None + data = await resp.json() + + latest_tag = data.get("tag_name", "").lstrip("v") + current_ver = info.metadata.version + + if not self._is_newer_plugin_version(latest_tag, current_ver): + return None + + # Find a suitable download asset + download_url = "" + download_size = None + for asset in data.get("assets", []): + if asset["name"].endswith((".tar.gz", ".zip")): + download_url = asset["browser_download_url"] + download_size = asset.get("size") + break + + # Fall back to source tarball + if not download_url: + download_url = data.get("tarball_url", "") + + return PluginUpdateInfo( + plugin_id=plugin_id, + current_version=current_ver, + available_version=latest_tag, + changelog=data.get("body", ""), + download_url=download_url, + download_size=download_size, + source_repo=repo, + ) + + @staticmethod + def _is_newer_plugin_version(latest: str, current: str) -> bool: + """Compare two version strings, returning True if latest > current.""" + def parse(v: str) -> tuple: + return tuple(int(x) for x in re.split(r'[-+]', v)[0].split('.')) + try: + return parse(latest) > parse(current) + except (ValueError, AttributeError): + return latest != current + # Global plugin manager instance _plugin_manager: Optional[PluginManager] = None diff --git a/app/backend/managers/update_manager.py b/app/backend/managers/update_manager.py index 0dded30..12ac6a4 100644 --- a/app/backend/managers/update_manager.py +++ b/app/backend/managers/update_manager.py @@ -1,26 +1,32 @@ """Update Manager for Dangerous Pi. Handles checking for updates from GitHub releases, downloading, -and applying updates to the system. +and applying updates to the system. Supports per-component updates +for pm3, frontend, backend, and theme components. """ import asyncio import hashlib import json import os +import platform import re import shutil +import sys import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, field, asdict from datetime import datetime, timedelta from enum import Enum from pathlib import Path from typing import Optional, Dict, Any, List import aiohttp -import aiosqlite from .. import config +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + class UpdateStatus(str, Enum): """Update status enum.""" IDLE = "idle" @@ -30,6 +36,63 @@ class UpdateStatus(str, Enum): INSTALLING = "installing" COMPLETE = "complete" FAILED = "failed" + ROLLING_BACK = "rolling_back" + + +class ComponentId(str, Enum): + """Known updateable component identifiers.""" + PM3 = "pm3" + FRONTEND = "frontend" + BACKEND = "backend" + THEME = "theme" + + +# --------------------------------------------------------------------------- +# Component manifest dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class ComponentInfo: + """Information about an installed component.""" + 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: + """Complete manifest of all installed components.""" + schema_version: int = 1 + system_version: str = "0.1.0" + components: Dict[str, ComponentInfo] = field(default_factory=dict) + plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict) + last_updated: str = "" + + +# --------------------------------------------------------------------------- +# Release / progress dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class AvailableComponent: + """A component update available for download.""" + component_id: str + current_version: Optional[str] + available_version: str + download_url: str + asset_name: str + asset_size: int + checksum: str + changelog: str + platform: Optional[str] = None + python_version: Optional[str] = None + compatible: bool = True + incompatible_reason: Optional[str] = None @dataclass @@ -38,14 +101,25 @@ class ReleaseInfo: version: str tag_name: str published_at: str - download_url: str changelog: str is_prerelease: bool - asset_name: str - asset_size: int + components: Dict[str, AvailableComponent] = field(default_factory=dict) + # Legacy fields for backward compatibility + download_url: str = "" + asset_name: str = "" + asset_size: int = 0 checksum: Optional[str] = None +@dataclass +class ComponentProgress: + """Progress for a single component update.""" + component_id: str + status: UpdateStatus + download_progress: float = 0.0 + error_message: Optional[str] = None + + @dataclass class UpdateProgress: """Update progress information.""" @@ -55,10 +129,20 @@ class UpdateProgress: download_progress: float = 0.0 error_message: Optional[str] = None last_check: Optional[str] = None + component_progress: Dict[str, ComponentProgress] = field(default_factory=dict) + active_component: Optional[str] = None class UpdateManager: - """Manages system updates from GitHub releases.""" + """Manages system updates from GitHub releases. + + Supports per-component updates for pm3, frontend, backend, and theme. + Tracks installed component versions via a manifest file. + """ + + MANIFEST_PATH = Path(config.COMPONENT_MANIFEST_PATH) + BACKUP_BASE = Path("/opt/dangerous-pi/backups") + INSTALL_DIR = Path("/opt/dangerous-pi") def __init__(self): """Initialize the update manager.""" @@ -71,10 +155,161 @@ class UpdateManager: current_version=self._current_version ) self._latest_release: Optional[ReleaseInfo] = None + self._available_components: Dict[str, AvailableComponent] = {} self._update_lock = asyncio.Lock() self._download_path: Optional[Path] = None self._check_interval = config.UPDATE_CHECK_INTERVAL + # Load or create component manifest + self._manifest = self._load_manifest() + self._current_version = self._manifest.system_version + + # ------------------------------------------------------------------ + # Manifest I/O + # ------------------------------------------------------------------ + + def _load_manifest(self) -> SystemManifest: + """Load component manifest from disk, migrating from legacy if needed.""" + if self.MANIFEST_PATH.exists(): + try: + data = json.loads(self.MANIFEST_PATH.read_text()) + return self._parse_manifest(data) + except (json.JSONDecodeError, KeyError, TypeError) as e: + print(f"Warning: failed to parse manifest, migrating: {e}") + + return self._migrate_from_legacy() + + def _parse_manifest(self, data: Dict[str, Any]) -> SystemManifest: + """Parse a manifest dict into a SystemManifest.""" + components = {} + for comp_id, comp_data in data.get("components", {}).items(): + components[comp_id] = ComponentInfo( + component_id=comp_data.get("component_id", comp_id), + version=comp_data.get("version", "unknown"), + installed_at=comp_data.get("installed_at", ""), + checksum=comp_data.get("checksum"), + install_path=comp_data.get("install_path", ""), + platform=comp_data.get("platform"), + python_version=comp_data.get("python_version"), + metadata=comp_data.get("metadata", {}), + ) + + return SystemManifest( + schema_version=data.get("schema_version", 1), + system_version=data.get("system_version", config.VERSION), + components=components, + plugins=data.get("plugins", {}), + last_updated=data.get("last_updated", ""), + ) + + def _save_manifest(self) -> None: + """Write the current manifest to disk.""" + self._manifest.last_updated = datetime.utcnow().isoformat() + + data = { + "schema_version": self._manifest.schema_version, + "system_version": self._manifest.system_version, + "components": { + comp_id: asdict(comp) + for comp_id, comp in self._manifest.components.items() + }, + "plugins": self._manifest.plugins, + "last_updated": self._manifest.last_updated, + } + + # Ensure parent directory exists + self.MANIFEST_PATH.parent.mkdir(parents=True, exist_ok=True) + self.MANIFEST_PATH.write_text(json.dumps(data, indent=2)) + + def _migrate_from_legacy(self) -> SystemManifest: + """Create a component manifest from legacy VERSION files.""" + # Read legacy system version + legacy_version_file = self.INSTALL_DIR / "VERSION" + if legacy_version_file.exists(): + system_version = legacy_version_file.read_text().strip() + else: + system_version = config.VERSION + + # Detect installed PM3 version + pm3_home = Path(config.PM3_CLIENT_PATH).parent.parent + pm3_version_file = pm3_home / "VERSION.txt" + pm3_version = "unknown" + if pm3_version_file.exists(): + pm3_version = pm3_version_file.read_text().strip().split("\n")[0] + + now = datetime.utcnow().isoformat() + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + manifest = SystemManifest( + schema_version=1, + system_version=system_version, + components={ + ComponentId.PM3: ComponentInfo( + component_id=ComponentId.PM3, + version=pm3_version, + installed_at=now, + install_path=str(pm3_home), + platform=f"{platform.machine()}-linux", + python_version=py_version, + metadata={"migrated_from_legacy": True}, + ), + ComponentId.FRONTEND: ComponentInfo( + component_id=ComponentId.FRONTEND, + version=system_version, + installed_at=now, + install_path=str(self.INSTALL_DIR / "app" / "frontend" / "build"), + metadata={"migrated_from_legacy": True}, + ), + ComponentId.BACKEND: ComponentInfo( + component_id=ComponentId.BACKEND, + version=system_version, + installed_at=now, + install_path=str(self.INSTALL_DIR / "app" / "backend"), + metadata={"migrated_from_legacy": True}, + ), + ComponentId.THEME: ComponentInfo( + component_id=ComponentId.THEME, + version=system_version, + installed_at=now, + install_path=str(self.INSTALL_DIR / "app" / "frontend" / "themes"), + metadata={"migrated_from_legacy": True}, + ), + }, + last_updated=now, + ) + + self._manifest = manifest + self._save_manifest() + print(f"✅ Migrated to component manifest (system v{system_version})") + return manifest + + # ------------------------------------------------------------------ + # Manifest accessors + # ------------------------------------------------------------------ + + def get_manifest(self) -> SystemManifest: + """Return the current system manifest.""" + return self._manifest + + def get_component_version(self, component_id: str) -> Optional[str]: + """Get the installed version of a component.""" + comp = self._manifest.components.get(component_id) + return comp.version if comp else None + + @staticmethod + def get_system_info() -> Dict[str, str]: + """Detect the current system's platform and Python version.""" + return { + "arch": platform.machine(), + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}", + "python_abi": f"cp{sys.version_info.major}{sys.version_info.minor}", + "os": "linux", + } + + # ------------------------------------------------------------------ + # Periodic checks + # ------------------------------------------------------------------ + async def start_periodic_checks(self): """Start periodic update checks in background.""" while True: @@ -87,18 +322,21 @@ class UpdateManager: print(f"Error in periodic update check: {e}") await asyncio.sleep(self._check_interval) + # ------------------------------------------------------------------ + # Check for updates (component-aware) + # ------------------------------------------------------------------ + async def check_for_updates(self) -> Dict[str, Any]: """Check for available updates from GitHub. - Returns: - Dict containing update status and info + Compares each component's installed version against the latest + release manifest. Returns per-component update availability. """ async with self._update_lock: try: self._status = UpdateStatus.CHECKING self._progress.status = UpdateStatus.CHECKING - # Fetch latest release from GitHub release = await self._fetch_latest_release() if not release: @@ -108,7 +346,8 @@ class UpdateManager: return { "update_available": False, "current_version": self._current_version, - "message": "No releases found" + "message": "No releases found", + "components": [], } # Trigger plugin hooks (non-critical) @@ -118,33 +357,79 @@ class UpdateManager: except Exception: pass - # Compare versions - if self._is_newer_version(release.version, self._current_version): - self._latest_release = release + # Check for plugin updates + plugin_updates = [] + try: + from .plugin_manager import get_plugin_manager + pm = get_plugin_manager() + plugin_update_infos = await pm.check_plugin_updates() + for pu in plugin_update_infos: + plugin_updates.append({ + "plugin_id": pu.plugin_id, + "current_version": pu.current_version, + "available_version": pu.available_version, + "changelog": pu.changelog, + "download_size": pu.download_size, + "source_repo": pu.source_repo, + }) + except Exception as e: + print(f"Warning: plugin update check failed: {e}") + + # Build per-component update list + self._latest_release = release + self._available_components = {} + component_updates = [] + any_update = False + + for comp_id, available in release.components.items(): + installed_ver = self.get_component_version(comp_id) + available.current_version = installed_ver + + if installed_ver and not self._is_newer_version( + available.available_version, installed_ver + ): + continue # Already up to date + + self._available_components[comp_id] = available + any_update = True + component_updates.append({ + "component_id": comp_id, + "current_version": installed_ver, + "available_version": available.available_version, + "changelog": available.changelog, + "download_size": available.asset_size, + "compatible": available.compatible, + "incompatible_reason": available.incompatible_reason, + }) + + # Also check system-level version (legacy compat) + system_update = self._is_newer_version( + release.version, self._current_version + ) + + now = datetime.utcnow().isoformat() + self._progress.last_check = now + + if any_update or system_update: self._status = UpdateStatus.AVAILABLE self._progress.status = UpdateStatus.AVAILABLE self._progress.available_version = release.version - - return { - "update_available": True, - "current_version": self._current_version, - "latest_version": release.version, - "release_date": release.published_at, - "changelog": release.changelog, - "is_prerelease": release.is_prerelease, - "download_size": release.asset_size - } else: self._status = UpdateStatus.IDLE self._progress.status = UpdateStatus.IDLE - self._progress.last_check = datetime.utcnow().isoformat() - return { - "update_available": False, - "current_version": self._current_version, - "latest_version": release.version, - "message": "System is up to date" - } + return { + "update_available": any_update or system_update + or len(plugin_updates) > 0, + "current_version": self._current_version, + "latest_version": release.version, + "release_date": release.published_at, + "changelog": release.changelog, + "is_prerelease": release.is_prerelease, + "download_size": release.asset_size, + "components": component_updates, + "plugins": plugin_updates, + } except Exception as e: self._status = UpdateStatus.FAILED @@ -152,180 +437,394 @@ class UpdateManager: self._progress.error_message = str(e) raise Exception(f"Failed to check for updates: {e}") - async def download_update(self) -> bool: - """Download the latest update. + # ------------------------------------------------------------------ + # Download (component-level and legacy) + # ------------------------------------------------------------------ - Returns: - True if download successful, False otherwise - """ + async def download_component(self, component_id: str) -> bool: + """Download a specific component update.""" async with self._update_lock: - if not self._latest_release: - raise ValueError("No update available to download") + available = self._available_components.get(component_id) + if not available: + raise ValueError(f"No update available for {component_id}") + + if not available.compatible: + raise ValueError( + f"Component {component_id} is not compatible: " + f"{available.incompatible_reason}" + ) try: + self._progress.active_component = component_id + comp_progress = ComponentProgress( + component_id=component_id, + status=UpdateStatus.DOWNLOADING, + ) + self._progress.component_progress[component_id] = comp_progress self._status = UpdateStatus.DOWNLOADING self._progress.status = UpdateStatus.DOWNLOADING - self._progress.download_progress = 0.0 - # Create temp directory for download - temp_dir = Path(tempfile.mkdtemp(prefix="dangerous-pi-update-")) - self._download_path = temp_dir / self._latest_release.asset_name + temp_dir = Path(tempfile.mkdtemp( + prefix=f"dangerous-pi-{component_id}-" + )) + download_path = temp_dir / available.asset_name - # Download the release asset async with aiohttp.ClientSession() as session: - async with session.get(self._latest_release.download_url) as response: + async with session.get(available.download_url) as response: if response.status != 200: - raise Exception(f"Download failed with status {response.status}") + raise Exception( + f"Download failed with status {response.status}" + ) - total_size = int(response.headers.get('content-length', 0)) + total_size = int( + response.headers.get('content-length', 0) + ) downloaded = 0 - with open(self._download_path, 'wb') as f: + with open(download_path, 'wb') as f: async for chunk in response.content.iter_chunked(8192): f.write(chunk) downloaded += len(chunk) if total_size > 0: - self._progress.download_progress = (downloaded / total_size) * 100 + pct = (downloaded / total_size) * 100 + comp_progress.download_progress = pct + self._progress.download_progress = pct - # Verify checksum if available - if self._latest_release.checksum: - if not await self._verify_checksum(self._download_path, self._latest_release.checksum): + # Verify checksum + if available.checksum: + if not self._verify_checksum(download_path, available.checksum): raise Exception("Checksum verification failed") + comp_progress.download_progress = 100.0 + comp_progress.status = UpdateStatus.IDLE self._progress.download_progress = 100.0 + + # Store path for install step + available.metadata = {"download_path": str(download_path)} # type: ignore[attr-defined] return True except Exception as e: + comp_progress.status = UpdateStatus.FAILED + comp_progress.error_message = str(e) self._status = UpdateStatus.FAILED self._progress.status = UpdateStatus.FAILED self._progress.error_message = str(e) + if 'temp_dir' in locals(): + shutil.rmtree(temp_dir, ignore_errors=True) + raise Exception(f"Failed to download {component_id}: {e}") - # Cleanup on failure - if self._download_path and self._download_path.parent.exists(): - shutil.rmtree(self._download_path.parent, ignore_errors=True) + async def download_update(self) -> bool: + """Download all available component updates (legacy compat).""" + for comp_id in list(self._available_components.keys()): + available = self._available_components[comp_id] + if available.compatible: + await self.download_component(comp_id) + return True - raise Exception(f"Failed to download update: {e}") + # ------------------------------------------------------------------ + # Install (component-level and legacy) + # ------------------------------------------------------------------ - async def install_update(self) -> bool: - """Install the downloaded update. - - Returns: - True if installation successful, False otherwise - """ + async def install_component(self, component_id: str) -> bool: + """Install a downloaded component update.""" async with self._update_lock: - if not self._download_path or not self._download_path.exists(): - raise ValueError("No update downloaded") + available = self._available_components.get(component_id) + if not available: + raise ValueError(f"No update available for {component_id}") + + download_path_str = getattr(available, 'metadata', {}).get( + 'download_path' + ) if hasattr(available, 'metadata') else None + if not download_path_str: + raise ValueError(f"Component {component_id} not downloaded yet") + + download_path = Path(download_path_str) + if not download_path.exists(): + raise ValueError(f"Download file missing for {component_id}") try: + self._progress.active_component = component_id + comp_progress = self._progress.component_progress.get( + component_id, + ComponentProgress(component_id=component_id, + status=UpdateStatus.INSTALLING) + ) + comp_progress.status = UpdateStatus.INSTALLING + self._progress.component_progress[component_id] = comp_progress self._status = UpdateStatus.INSTALLING self._progress.status = UpdateStatus.INSTALLING - # Extract the update archive - install_dir = Path("/opt/dangerous-pi") - backup_dir = Path("/opt/dangerous-pi-backup") + # Backup current component + await self._backup_component(component_id) - # Create backup of current installation - if install_dir.exists(): - if backup_dir.exists(): - shutil.rmtree(backup_dir) - shutil.copytree(install_dir, backup_dir) - - # Extract update (assuming tar.gz format) + # Extract to staging directory + staging_dir = Path(tempfile.mkdtemp( + prefix=f"dangerous-pi-stage-{component_id}-" + )) await self._run_command( - f"tar -xzf {self._download_path} -C {install_dir.parent}" + f"tar -xzf {download_path} -C {staging_dir}" ) - # Run post-install script if exists - post_install = install_dir / "scripts" / "post-install.sh" - if post_install.exists(): - await self._run_command(f"sudo bash {post_install}") + # Run component-specific installer + installer = self._get_installer(component_id) + await installer(staging_dir, available) - # Rebuild PM3 client if needed - await self._rebuild_pm3_client() - - # Update version in config - await self._update_version_file(self._latest_release.version) + # Update manifest + now = datetime.utcnow().isoformat() + comp_info = self._manifest.components.get( + component_id, ComponentInfo( + component_id=component_id, + version="unknown", + installed_at=now, + ) + ) + comp_info.version = available.available_version + comp_info.installed_at = now + comp_info.checksum = available.checksum + self._manifest.components[component_id] = comp_info + self._save_manifest() # Cleanup - shutil.rmtree(self._download_path.parent, ignore_errors=True) - self._download_path = None + shutil.rmtree(download_path.parent, ignore_errors=True) + shutil.rmtree(staging_dir, ignore_errors=True) + comp_progress.status = UpdateStatus.COMPLETE self._status = UpdateStatus.COMPLETE self._progress.status = UpdateStatus.COMPLETE - self._current_version = self._latest_release.version - self._progress.current_version = self._latest_release.version + + # Remove from available + self._available_components.pop(component_id, None) return True except Exception as e: + comp_progress.status = UpdateStatus.FAILED + comp_progress.error_message = str(e) self._status = UpdateStatus.FAILED self._progress.status = UpdateStatus.FAILED self._progress.error_message = str(e) - # Restore backup on failure - if backup_dir.exists(): - if install_dir.exists(): - shutil.rmtree(install_dir) - shutil.copytree(backup_dir, install_dir) + # Attempt rollback + try: + await self.rollback_component(component_id) + except Exception as rb_err: + print(f"Rollback failed for {component_id}: {rb_err}") - raise Exception(f"Failed to install update: {e}") + raise Exception(f"Failed to install {component_id}: {e}") + + async def install_update(self) -> bool: + """Install all downloaded component updates (legacy compat).""" + for comp_id in list(self._available_components.keys()): + await self.install_component(comp_id) + + # Update system version + if self._latest_release: + self._current_version = self._latest_release.version + self._manifest.system_version = self._latest_release.version + self._progress.current_version = self._latest_release.version + self._save_manifest() + + return True + + # ------------------------------------------------------------------ + # Component-specific installers + # ------------------------------------------------------------------ + + def _get_installer(self, component_id: str): + """Return the installer function for a component.""" + installers = { + ComponentId.PM3: self._install_pm3, + ComponentId.FRONTEND: self._install_frontend, + ComponentId.BACKEND: self._install_backend, + ComponentId.THEME: self._install_theme, + } + installer = installers.get(component_id) + if not installer: + raise ValueError(f"Unknown component: {component_id}") + return installer + + async def _install_pm3(self, staging_dir: Path, info: AvailableComponent): + """Install PM3 client + firmware from pre-built binary.""" + pm3_home = Path(config.PM3_CLIENT_PATH).parent.parent + # The tarball extracts to pm3/ subdirectory + source = staging_dir / "pm3" + if not source.exists(): + # Try finding the extracted directory + dirs = [d for d in staging_dir.iterdir() if d.is_dir()] + source = dirs[0] if dirs else staging_dir + + # Copy files preserving structure + for item in source.rglob("*"): + if item.is_file(): + rel = item.relative_to(source) + dest = pm3_home / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, dest) + + # Ensure _pm3.so symlink exists + pyscripts = pm3_home / "client" / "pyscripts" + pm3_so = pyscripts / "_pm3.so" + lib_so = pyscripts / "libpm3rrg_rdv4.so" + if lib_so.exists() and not pm3_so.exists(): + pm3_so.symlink_to("libpm3rrg_rdv4.so") + + # Make executables executable + client_bin = pm3_home / "client" / "proxmark3" + if client_bin.exists(): + client_bin.chmod(0o755) + for flash_script in pm3_home.glob("pm3-flash-*"): + flash_script.chmod(0o755) + + async def _install_frontend(self, staging_dir: Path, info: AvailableComponent): + """Install frontend build output.""" + target = self.INSTALL_DIR / "app" / "frontend" / "build" + source = staging_dir / "frontend" / "build" + if not source.exists(): + source = staging_dir / "frontend" + + if target.exists(): + shutil.rmtree(target) + shutil.copytree(source, target) + + async def _install_backend(self, staging_dir: Path, info: AvailableComponent): + """Install backend Python source and update dependencies.""" + target = self.INSTALL_DIR / "app" / "backend" + source = staging_dir / "backend" / "app" / "backend" + if not source.exists(): + source = staging_dir / "backend" + + if target.exists(): + shutil.rmtree(target) + shutil.copytree(source, target) + + # Update pip dependencies if requirements.txt included + req_file = staging_dir / "backend" / "requirements.txt" + if req_file.exists(): + shutil.copy2(req_file, self.INSTALL_DIR / "requirements.txt") + try: + await self._run_command( + f"pip install -r {self.INSTALL_DIR / 'requirements.txt'} " + f"--quiet --disable-pip-version-check" + ) + except Exception as e: + print(f"Warning: pip install failed: {e}") + + async def _install_theme(self, staging_dir: Path, info: AvailableComponent): + """Install theme token CSS files.""" + target = self.INSTALL_DIR / "app" / "frontend" / "themes" + source = staging_dir / "theme" + if not source.exists(): + source = staging_dir + + target.mkdir(parents=True, exist_ok=True) + + # Copy theme directories (dt/, classic/, supra/, themes.json) + for item in source.iterdir(): + dest = target / item.name + if item.name == "COMPONENT_VERSION": + continue + if item.is_dir(): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(item, dest) + elif item.is_file(): + shutil.copy2(item, dest) + + # ------------------------------------------------------------------ + # Backup & rollback + # ------------------------------------------------------------------ + + async def _backup_component(self, component_id: str) -> Path: + """Backup current component before installing update.""" + backup_dir = self.BACKUP_BASE / component_id + if backup_dir.exists(): + shutil.rmtree(backup_dir) + backup_dir.mkdir(parents=True, exist_ok=True) + + comp = self._manifest.components.get(component_id) + if comp and comp.install_path: + source = Path(comp.install_path) + if source.exists(): + shutil.copytree(source, backup_dir / "files", + dirs_exist_ok=True) + + # Save component info for rollback + info_file = backup_dir / "component-info.json" + info_file.write_text(json.dumps(asdict(comp), indent=2)) + + return backup_dir + + async def rollback_component(self, component_id: str) -> bool: + """Rollback a component to its backup.""" + backup_dir = self.BACKUP_BASE / component_id + if not backup_dir.exists(): + raise ValueError(f"No backup found for {component_id}") + + self._status = UpdateStatus.ROLLING_BACK + self._progress.status = UpdateStatus.ROLLING_BACK + + info_file = backup_dir / "component-info.json" + if not info_file.exists(): + raise ValueError(f"Backup metadata missing for {component_id}") + + old_info = json.loads(info_file.read_text()) + target = Path(old_info["install_path"]) + + # Restore files + backup_files = backup_dir / "files" + if backup_files.exists(): + if target.exists(): + shutil.rmtree(target) + shutil.copytree(backup_files, target) + + # Restore manifest entry + self._manifest.components[component_id] = ComponentInfo(**old_info) + self._save_manifest() + + self._status = UpdateStatus.IDLE + self._progress.status = UpdateStatus.IDLE + return True + + # ------------------------------------------------------------------ + # Progress & release notes + # ------------------------------------------------------------------ async def get_progress(self) -> UpdateProgress: - """Get current update progress. - - Returns: - UpdateProgress object - """ + """Get current update progress.""" return self._progress async def get_release_notes(self, version: Optional[str] = None) -> str: - """Get release notes for a specific version. - - Args: - version: Version to get notes for (latest if None) - - Returns: - Release notes as markdown string - """ + """Get release notes for a specific version.""" try: if version: release = await self._fetch_release_by_version(version) else: release = await self._fetch_latest_release() - return release.changelog if release else "No release notes available" - except Exception as e: raise Exception(f"Failed to get release notes: {e}") - async def _fetch_latest_release(self) -> Optional[ReleaseInfo]: - """Fetch latest release from GitHub API. + # ------------------------------------------------------------------ + # GitHub API + # ------------------------------------------------------------------ - Returns: - ReleaseInfo object or None - """ + async def _fetch_latest_release(self) -> Optional[ReleaseInfo]: + """Fetch latest release from GitHub API.""" url = f"{self._github_api_base}/repos/{self._github_repo}/releases/latest" async with aiohttp.ClientSession() as session: async with session.get(url) as response: if response.status == 404: return None - if response.status != 200: - raise Exception(f"GitHub API returned status {response.status}") - + raise Exception( + f"GitHub API returned status {response.status}" + ) data = await response.json() - return self._parse_release_data(data) + return await self._parse_release_data(data, session) async def _fetch_release_by_version(self, version: str) -> Optional[ReleaseInfo]: - """Fetch specific release by version tag. - - Args: - version: Version tag (e.g., "v1.0.0") - - Returns: - ReleaseInfo object or None - """ + """Fetch specific release by version tag.""" tag = version if version.startswith('v') else f'v{version}' url = f"{self._github_api_base}/repos/{self._github_repo}/releases/tags/{tag}" @@ -333,144 +832,193 @@ class UpdateManager: async with session.get(url) as response: if response.status == 404: return None - if response.status != 200: - raise Exception(f"GitHub API returned status {response.status}") - + raise Exception( + f"GitHub API returned status {response.status}" + ) data = await response.json() - return self._parse_release_data(data) + return await self._parse_release_data(data, session) - def _parse_release_data(self, data: Dict[str, Any]) -> ReleaseInfo: + async def _parse_release_data( + self, data: Dict[str, Any], session: aiohttp.ClientSession + ) -> ReleaseInfo: """Parse GitHub release API response. - Args: - data: GitHub API release data - - Returns: - ReleaseInfo object + Supports two formats: + 1. New-style: release has a release-manifest.json asset + 2. Legacy: release has a single .tar.gz asset """ - # Find the main release asset (tar.gz) + version = data['tag_name'].lstrip('v') assets = data.get('assets', []) - main_asset = None - checksum_asset = None + + # Look for release-manifest.json (new-style) + manifest_asset = None + legacy_asset = None for asset in assets: name = asset['name'] - if name.endswith('.tar.gz'): - main_asset = asset - elif name.endswith('.sha256'): - checksum_asset = asset + if name == 'release-manifest.json': + manifest_asset = asset + elif name.endswith('.tar.gz') and legacy_asset is None: + legacy_asset = asset - if not main_asset: - raise ValueError("No suitable release asset found") - - # Get version from tag (remove 'v' prefix) - version = data['tag_name'].lstrip('v') - - # Get checksum if available - checksum = None - if checksum_asset: - # Would need to download and read checksum file - # For now, we'll skip this step - pass - - return ReleaseInfo( + release = ReleaseInfo( version=version, tag_name=data['tag_name'], published_at=data['published_at'], - download_url=main_asset['browser_download_url'], changelog=data.get('body', ''), is_prerelease=data.get('prerelease', False), - asset_name=main_asset['name'], - asset_size=main_asset['size'], - checksum=checksum ) + if manifest_asset: + # New-style: parse component manifest + await self._parse_component_manifest( + manifest_asset, assets, release, session + ) + elif legacy_asset: + # Legacy: single tarball + release.download_url = legacy_asset['browser_download_url'] + release.asset_name = legacy_asset['name'] + release.asset_size = legacy_asset['size'] + + return release + + async def _parse_component_manifest( + self, + manifest_asset: Dict[str, Any], + all_assets: List[Dict[str, Any]], + release: ReleaseInfo, + session: aiohttp.ClientSession, + ) -> None: + """Download and parse release-manifest.json, populating release.components.""" + manifest_url = manifest_asset['browser_download_url'] + async with session.get(manifest_url) as resp: + if resp.status != 200: + return + manifest_data = await resp.json() + + # Build asset URL lookup + asset_urls = {a['name']: a for a in all_assets} + sys_info = self.get_system_info() + + for comp_id, comp_data in manifest_data.get("components", {}).items(): + comp_ver = comp_data.get("version", release.version) + comp_assets = comp_data.get("assets", {}) + changelog = comp_data.get("changelog", "") + + # Select the right asset variant for this system + selected_key, selected_asset = self._select_asset( + comp_id, comp_assets, sys_info + ) + + if not selected_asset: + continue + + filename = selected_asset.get("filename", "") + gh_asset = asset_urls.get(filename, {}) + + available = AvailableComponent( + component_id=comp_id, + current_version=self.get_component_version(comp_id), + available_version=comp_ver, + download_url=gh_asset.get("browser_download_url", ""), + asset_name=filename, + asset_size=selected_asset.get("size", gh_asset.get("size", 0)), + checksum=selected_asset.get("checksum_sha256", ""), + changelog=changelog, + platform=selected_asset.get("platform"), + python_version=selected_asset.get("python_version"), + compatible=True, + ) + + # Sum total asset size for legacy compat + release.asset_size += available.asset_size + release.components[comp_id] = available + + # Check for incompatible pm3 variants + if ComponentId.PM3 not in release.components: + # Check if pm3 exists but no compatible variant + pm3_data = manifest_data.get("components", {}).get(ComponentId.PM3) + if pm3_data and pm3_data.get("assets"): + release.components[ComponentId.PM3] = AvailableComponent( + component_id=ComponentId.PM3, + current_version=self.get_component_version(ComponentId.PM3), + available_version=pm3_data.get("version", release.version), + download_url="", + asset_name="", + asset_size=0, + checksum="", + changelog=pm3_data.get("changelog", ""), + compatible=False, + incompatible_reason=( + f"No binary for {sys_info['arch']}-linux " + f"with Python {sys_info['python_version']}" + ), + ) + + def _select_asset( + self, + component_id: str, + assets: Dict[str, Any], + sys_info: Dict[str, str], + ) -> tuple: + """Select the correct asset variant for this system.""" + if not assets: + return None, None + + # For pm3, match platform + python ABI + if component_id == ComponentId.PM3: + key = f"{sys_info['arch']}-linux-{sys_info['python_abi']}" + if key in assets: + return key, assets[key] + return None, None + + # For other components, use "default" key or first available + if "default" in assets: + return "default", assets["default"] + + # Take the first (and usually only) asset + first_key = next(iter(assets)) + return first_key, assets[first_key] + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + def _is_newer_version(self, version1: str, version2: str) -> bool: - """Compare two semantic versions. - - Args: - version1: First version (e.g., "1.2.3") - version2: Second version (e.g., "1.1.0") - - Returns: - True if version1 is newer than version2 - """ + """Compare two version strings. Returns True if version1 > version2.""" def parse_version(v: str) -> tuple: - # Remove 'v' prefix and split v = v.lstrip('v') - parts = re.split(r'[-+]', v)[0] # Remove pre-release/build metadata + parts = re.split(r'[-+]', v)[0] return tuple(map(int, parts.split('.'))) try: - v1_parts = parse_version(version1) - v2_parts = parse_version(version2) - return v1_parts > v2_parts + return parse_version(version1) > parse_version(version2) except (ValueError, AttributeError): - return False + # Fall back to string comparison for non-semver (e.g. PM3 tags) + return version1 != version2 - async def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool: - """Verify file checksum. - - Args: - file_path: Path to file to verify - expected_checksum: Expected SHA256 checksum - - Returns: - True if checksum matches, False otherwise - """ + def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool: + """Verify SHA256 checksum of a file.""" sha256 = hashlib.sha256() - with open(file_path, 'rb') as f: while True: - data = f.read(65536) # 64KB chunks + data = f.read(65536) if not data: break sha256.update(data) - - actual_checksum = sha256.hexdigest() - return actual_checksum.lower() == expected_checksum.lower() - - async def _rebuild_pm3_client(self): - """Rebuild Proxmark3 client after update.""" - pm3_dir = Path("/opt/proxmark3") - - if not pm3_dir.exists(): - print("Proxmark3 directory not found, skipping rebuild") - return - - # Run make clean and make - await self._run_command(f"cd {pm3_dir} && make clean && make") - - async def _update_version_file(self, version: str): - """Update version file with new version. - - Args: - version: New version string - """ - version_file = Path("/opt/dangerous-pi/VERSION") - version_file.write_text(version) + return sha256.hexdigest().lower() == expected_checksum.lower() async def _run_command(self, command: str) -> str: - """Run a shell command. - - Args: - command: Command to run - - Returns: - Command output - """ + """Run a shell command asynchronously.""" process = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - stdout, stderr = await process.communicate() - if process.returncode != 0: raise Exception(f"Command failed: {stderr.decode()}") - return stdout.decode() diff --git a/app/backend/services/update_service.py b/app/backend/services/update_service.py index a2290fb..a37c70b 100644 --- a/app/backend/services/update_service.py +++ b/app/backend/services/update_service.py @@ -3,9 +3,12 @@ This service provides software update operations that can be consumed by multiple interfaces (REST API, BLE GATT, etc.). """ -from typing import Optional, Dict, Any +from dataclasses import asdict +from typing import Optional, Dict, Any, List -from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus +from ..managers.update_manager import ( + UpdateManager, UpdateProgress, UpdateStatus, ComponentId, +) from .pm3_service import PM3ServiceError, PM3ServiceResult @@ -156,6 +159,15 @@ class UpdateService: try: progress = await self.update_manager.get_progress() + comp_list = [] + for comp_id, cp in progress.component_progress.items(): + comp_list.append({ + "component_id": cp.component_id, + "status": cp.status.value, + "download_progress": cp.download_progress, + "error_message": cp.error_message, + }) + return PM3ServiceResult( success=True, data={ @@ -164,7 +176,9 @@ class UpdateService: "available_version": progress.available_version, "download_progress": progress.download_progress, "error_message": progress.error_message, - "last_check": progress.last_check + "last_check": progress.last_check, + "active_component": progress.active_component, + "components": comp_list, } ) @@ -310,3 +324,151 @@ class UpdateService: details=str(e) ) ) + + # ------------------------------------------------------------------ + # Component-level operations + # ------------------------------------------------------------------ + + async def get_installed_components(self) -> PM3ServiceResult: + """Get the installed component manifest. + + Returns: + PM3ServiceResult with component manifest data + """ + try: + manifest = self.update_manager.get_manifest() + components = {} + for comp_id, comp in manifest.components.items(): + components[comp_id] = asdict(comp) + + return PM3ServiceResult( + success=True, + data={ + "schema_version": manifest.schema_version, + "system_version": manifest.system_version, + "components": components, + "plugins": manifest.plugins, + "last_updated": manifest.last_updated, + } + ) + except Exception as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="manifest_error", + message="Failed to get component manifest", + details=str(e) + ) + ) + + async def download_component(self, component_id: str) -> PM3ServiceResult: + """Download a specific component update. + + Args: + component_id: Component to download (pm3, frontend, backend, theme) + + Returns: + PM3ServiceResult indicating download success/failure + """ + try: + success = await self.update_manager.download_component(component_id) + return PM3ServiceResult( + success=True, + data={ + "message": f"Component {component_id} downloaded successfully", + "component_id": component_id, + "ready_to_install": True, + } + ) + except ValueError as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="component_not_available", + message=str(e) + ) + ) + except Exception as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="component_download_error", + message=f"Error downloading {component_id}", + details=str(e) + ) + ) + + async def install_component(self, component_id: str) -> PM3ServiceResult: + """Install a downloaded component update. + + Args: + component_id: Component to install + + Returns: + PM3ServiceResult indicating install success/failure + """ + try: + success = await self.update_manager.install_component(component_id) + return PM3ServiceResult( + success=True, + data={ + "message": f"Component {component_id} installed successfully", + "component_id": component_id, + "requires_restart": component_id in ( + ComponentId.BACKEND, ComponentId.PM3 + ), + } + ) + except ValueError as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="component_install_error", + message=str(e) + ) + ) + except Exception as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="component_install_error", + message=f"Error installing {component_id}", + details=str(e) + ) + ) + + async def rollback_component(self, component_id: str) -> PM3ServiceResult: + """Rollback a component to its previous version. + + Args: + component_id: Component to rollback + + Returns: + PM3ServiceResult indicating rollback success/failure + """ + try: + success = await self.update_manager.rollback_component(component_id) + return PM3ServiceResult( + success=True, + data={ + "message": f"Component {component_id} rolled back successfully", + "component_id": component_id, + } + ) + except ValueError as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="rollback_error", + message=str(e) + ) + ) + except Exception as e: + return PM3ServiceResult( + success=False, + error=PM3ServiceError( + code="rollback_error", + message=f"Error rolling back {component_id}", + details=str(e) + ) + ) diff --git a/app/frontend/app/components/ConnectDialog.tsx b/app/frontend/app/components/ConnectDialog.tsx index 44959eb..4b929d5 100644 --- a/app/frontend/app/components/ConnectDialog.tsx +++ b/app/frontend/app/components/ConnectDialog.tsx @@ -33,7 +33,7 @@ export default function ConnectDialog({ network, onClose, isSubmitting }: Connec left: 0, right: 0, bottom: 0, - background: "rgba(10, 14, 26, 0.9)", + background: "rgba(var(--color-bg-rgb), 0.9)", backdropFilter: "blur(8px)", display: "flex", alignItems: "center", diff --git a/app/frontend/app/components/DeviceSelector.tsx b/app/frontend/app/components/DeviceSelector.tsx index 2c153d0..cf36886 100644 --- a/app/frontend/app/components/DeviceSelector.tsx +++ b/app/frontend/app/components/DeviceSelector.tsx @@ -279,7 +279,7 @@ export default function DeviceSelector({ style={{ marginBottom: "var(--space-3)", padding: "var(--space-3)", - background: "rgba(239, 68, 68, 0.1)", + background: "rgba(var(--color-error-rgb), 0.1)", borderLeft: "3px solid var(--color-error)", borderRadius: "var(--radius)", }} @@ -318,7 +318,7 @@ export default function DeviceSelector({ cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed", opacity: isSelectable || isFlashing ? 1 : 0.6, transition: "all 150ms ease", - background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent", + background: isSelected ? "rgba(var(--color-primary-rgb), 0.05)" : "transparent", position: "relative", }} onClick={() => { @@ -336,7 +336,7 @@ export default function DeviceSelector({ left: 0, right: 0, bottom: 0, - background: "rgba(0, 0, 0, 0.8)", + background: "rgba(var(--color-bg-rgb), 0.8)", borderRadius: "var(--radius)", display: "flex", flexDirection: "column", @@ -442,7 +442,7 @@ export default function DeviceSelector({ style={{ marginTop: "var(--space-2)", padding: "var(--space-2)", - background: "rgba(217, 119, 6, 0.1)", + background: "rgba(var(--color-warning-rgb), 0.1)", borderLeft: "3px solid var(--color-warning)", borderRadius: "var(--radius)", fontSize: "0.75rem", @@ -538,7 +538,7 @@ export default function DeviceSelector({ left: 0, right: 0, bottom: 0, - background: "rgba(0, 0, 0, 0.75)", + background: "rgba(var(--color-bg-rgb), 0.75)", display: "flex", justifyContent: "center", alignItems: "center", @@ -600,7 +600,7 @@ export default function DeviceSelector({
diff --git a/app/frontend/app/components/QRScanner.tsx b/app/frontend/app/components/QRScanner.tsx index 502757f..06d890b 100644 --- a/app/frontend/app/components/QRScanner.tsx +++ b/app/frontend/app/components/QRScanner.tsx @@ -155,7 +155,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps) left: 0, right: 0, bottom: 0, - background: "rgba(10, 14, 26, 0.95)", + background: "rgba(var(--color-bg-rgb), 0.95)", backdropFilter: "blur(8px)", display: "flex", flexDirection: "column", @@ -203,7 +203,7 @@ export default function QRScanner({ onScan, onClose, onError }: QRScannerProps)
void; + setMode: (mode: ThemeMode) => void; + themeDefinition: ThemeDefinition; + availableThemes: ThemeDefinition[]; +} + +const ThemeContext = createContext(null); + +/** + * Inject or update a 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(() => { + if (typeof window === "undefined") return DEFAULT_THEME; + return localStorage.getItem(BRAND_STORAGE_KEY) || DEFAULT_THEME; + }); + + const [mode, setModeState] = useState(() => { + if (typeof window === "undefined") return "dark"; + return (localStorage.getItem(MODE_STORAGE_KEY) as ThemeMode) || "dark"; + }); + + const [availableThemes, setAvailableThemes] = useState(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 ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} diff --git a/app/frontend/app/root.tsx b/app/frontend/app/root.tsx index f0abc58..4e9b8ce 100644 --- a/app/frontend/app/root.tsx +++ b/app/frontend/app/root.tsx @@ -7,27 +7,40 @@ import { ScrollRestoration, useLocation, } from "@remix-run/react"; -import { useState, useEffect } from "react"; -import styles from "./styles.css?url"; +import baseStyles from "./styles/base.css?url"; +import themeStyles from "@dangerousthings/web/dist/index.css?url"; import { PowerWidget } from "./components/PowerWidget"; import { HeaderWidgets } from "./components/HeaderWidgets"; import { LoginPrompt } from "./components/LoginPrompt"; import { useWebSocketState } from "./hooks/useWebSocket"; +import { ThemeProvider, useTheme } from "./lib/ThemeContext"; +import type { ThemeMode } from "./lib/ThemeContext"; export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, + { rel: "stylesheet", href: baseStyles }, + { rel: "stylesheet", href: themeStyles }, + { rel: "preload", href: "/fonts/Tektur-VariableFont_wdth,wght.ttf", as: "font", type: "font/ttf", crossOrigin: "anonymous" }, ]; +/** + * FOUC prevention: static inline script reads localStorage and sets + * data-brand/data-theme before first paint. This is a hardcoded string + * constant (no user input), so it is safe from XSS. + */ +const FOUC_PREVENTION_SCRIPT = `(function(){try{var b=localStorage.getItem('dpi_theme_brand')||'dt';var m=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-brand',b);document.documentElement.setAttribute('data-theme',m);}catch(e){}})();`; + export function Layout({ children }: { children: React.ReactNode }) { return ( - + - + + {/* Prevent FOUC: hardcoded constant string, no user input — safe from XSS */} +