Phase 4 enhancements: WebSocket auth, HTTPS UI, plugin hooks, build fixes

Security:
- Add token-based WebSocket authentication (closes critical security gap)
  - In-memory token store with 24h TTL (token_store.py)
  - POST /api/auth/token exchanges Basic Auth for WS token
  - GET /api/auth/status public endpoint for auth check
  - WebSocket validates token query param, rejects with close code 4401
  - Frontend LoginPrompt modal for credential entry
  - WebSocket manager handles full auth flow with auth_required state
  - No-op when AUTH_ENABLED=false (preserves existing behavior)

HTTPS:
- Wire HTTPS toggle in Settings UI (POST /api/system/ssl/toggle)
- Add certificate regeneration button
- Display SSL info (expiration, SANs, SHA256 fingerprint)

Plugins:
- Wire trigger_hook("pm3_command") in PM3 service
- Wire trigger_hook("update_check") in update manager

Build/Infrastructure:
- Enable NetworkManager in pi-gen AP setup stage
- Add HF booster board detection patch for Proxmark3
- Update LED PWM control patch
- Fix BLE adapter, UPS drivers, WiFi manager improvements
- Update HTTPS support stage script

Documentation:
- Update PROJECT_STATUS.md and IMPLEMENTATION_PRIORITIES.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-03 11:45:11 -08:00
parent 4f35df1781
commit 2ec89041ef
24 changed files with 1249 additions and 362 deletions

View File

@@ -1,239 +1,144 @@
# Implementation Priorities - Updated # Implementation Priorities - Updated
**Date**: 2025-11-26 **Date**: 2026-03-03
**Status**: Active Development **Status**: Active Development - Phase 4 Enhancement
--- ---
## 🔴 PRIORITY 1: Power Management (BEFORE PI TESTING) ## 📊 Current Feature Status (2026-03-03)
**Why Critical**: System behavior must be defined for UPS-present and UPS-absent scenarios before deploying to hardware. | Feature | Status | Notes |
|---------|--------|-------|
### Tasks | **HTTPS** | ✅ 100% | Toggle, cert info, regenerate all working |
| **Authentication** | 40% | Basic + WS token auth done; JWT/RBAC remaining |
#### 1.1 UPS Manager - Power Restrictions Method ⚠️ CRITICAL | **Plugin System** | 85% | Hooks wired; remote install remaining |
- **File**: `app/backend/managers/ups_manager.py`
- **Action**: Add `get_power_restrictions()` method
- **Estimated Time**: 30 minutes
- **Blocks**: All hardware testing
**Implementation**:
```python
def get_power_restrictions(self) -> Dict[str, Any]:
"""Get current power restrictions based on hardware state."""
# See POWER_MANAGEMENT_POLICY.md for full implementation
```
#### 1.2 System API - Power Restrictions Endpoint
- **File**: `app/backend/api/system.py`
- **Action**: Add `GET /api/system/power/restrictions` endpoint
- **Estimated Time**: 15 minutes
- **Depends on**: 1.1
**Implementation**:
```python
@router.get("/power/restrictions")
async def get_power_restrictions():
"""Get current power restrictions."""
# See POWER_MANAGEMENT_POLICY.md
```
#### 1.3 Header Widget - UPS Missing Warning
- **File**: Multiple (plugin_manager, ups_manager)
- **Action**: Register widget when UPS not detected
- **Estimated Time**: 20 minutes
- **Depends on**: Header widget system (can defer to Phase 2)
#### 1.4 Documentation Update
- **Files**:
- `README.md` - Add power management section
- `GETTING_STARTED.md` - Mention UPS optional
- `PROJECT_STATUS.md` - Update status
- **Estimated Time**: 15 minutes
**Total Time**: ~1.5 hours for Phase 1 (core functionality)
--- ---
## 🟡 PRIORITY 2: Header Widget System (OPTIONAL FOR TESTING) ## ✅ COMPLETED: Quick Wins (Done 2026-03-03)
**Why Important**: Good UX, but not required for basic Pi testing ### ~~1.1 HTTPS Settings UI~~ ✅ DONE
- [x] Enable/Disable toggle via `POST /api/system/ssl/toggle`
- [x] Regenerate Certificate button
- [x] SSL info display (expiration, SANs, SHA256 fingerprint)
### Tasks ### ~~1.2 Plugin Hook Wiring~~ ✅ DONE
- [x] `trigger_hook("pm3_command", command)` in `pm3_service.py`
#### 2.1 Backend Widget Infrastructure - [x] `trigger_hook("update_check")` in `update_manager.py`
- Add `HeaderWidget` dataclass to plugin_manager
- Add widget registry methods
- Add dismissed widgets tracking
- **Estimated Time**: 1 hour
#### 2.2 API Endpoints
- `GET /api/system/widgets`
- `POST /api/system/widgets/{id}/dismiss`
- **Estimated Time**: 30 minutes
#### 2.3 Frontend Component
- Create `HeaderWidgets.tsx`
- Add CSS styles
- Integrate into `root.tsx`
- **Estimated Time**: 1.5 hours
#### 2.4 UPS Integration
- Register widget on missing hardware
- Register widget on low battery
- **Estimated Time**: 30 minutes
**Total Time**: ~3.5 hours
--- ---
## 🟢 PRIORITY 3: Pi-gen Build Testing (READY TO GO) ## ✅ ~~PRIORITY 2: WebSocket Security~~ DONE (2026-03-03)
**Status**: Scripts complete, ready for build - [x] Token store with 24h TTL (`app/backend/api/token_store.py`)
- [x] `POST /api/auth/token` - exchanges Basic Auth for WS token
### Tasks - [x] `GET /api/auth/status` - public endpoint to check if auth enabled
- [x] WebSocket validates token query param, closes with 4401 if invalid
#### 3.1 Test Build - [x] Frontend fetches token, passes as `?token=` on WS URL
- Run `./build-image.sh quick` - [x] LoginPrompt modal shown when WS returns auth_required
- Verify PM3 client builds - [x] No-op when AUTH_ENABLED=false (existing behavior preserved)
- Verify Python bindings work
- **Estimated Time**: 30 minutes (build time 5-10 min)
#### 3.2 Fix Issues
- Address any build failures
- Update scripts as needed
- **Estimated Time**: Variable
#### 3.3 Full Build
- Run `./build-image.sh full`
- Create complete bootable image
- **Estimated Time**: 1-2 hours (build time)
**Total Time**: 2-3 hours
--- ---
## 🔵 PRIORITY 4: Hardware Testing ## 🟢 PRIORITY 3: Full Authentication System (~3-4 weeks)
**Prerequisites**: Priorities 1 & 3 complete **Why Important:** Required for production deployment, but large effort.
### 4.1 Deploy to Pi Zero 2 W ### Phase A: JWT Implementation (~3-5 days)
- Flash SD card with custom image - [ ] Implement JWT token generation/validation
- Boot and verify services start - [ ] Create `/api/auth/login` endpoint
- Test web interface access - [ ] Create `/api/auth/logout` endpoint
- [ ] Token refresh mechanism
### 4.2 Test Without UPS ### Phase B: Login UI (~2-3 days)
- Verify power restrictions return "assumed AC" - [ ] Create login page (`app/frontend/app/routes/login.tsx`)
- Verify header widget shows warning - [ ] Auth context/state management
- Verify no unexpected restrictions - [ ] Protected route handling
- [ ] 401 error handling and redirect
### 4.3 Test With UPS (if available) ### Phase C: User Management (~3-5 days)
- Connect UPS HAT - [ ] User database schema
- Verify battery monitoring works - [ ] Password hashing (bcrypt/argon2)
- Test power restrictions on battery - [ ] User CRUD endpoints
- Test AC power detection - [ ] Multi-user support
### 4.4 Test PM3 Operations ### Phase D: RBAC (~3-5 days)
- Connect PM3 device - [ ] Role definitions (admin, user, guest)
- Execute commands - [ ] Permission system
- Test multi-device (if available) - [ ] Endpoint-level authorization
**Total Time**: 2-4 hours
--- ---
## 🟣 PRIORITY 5: Future Enhancements (POST-MVP) ## 🔵 PRIORITY 4: Plugin Ecosystem (~2-3 weeks)
- Firmware flashing UI (Sprint 3) ### 4.1 Remote Installation (~1 week)
- Advanced header widgets - [ ] GitHub releases integration
- Plugin ecosystem - [ ] Download/extract/verify plugins
- Multi-device hardware testing - [ ] pip dependency installation
### 4.2 Permission Consent UI (~2-3 days)
- [ ] Show permissions before enabling
- [ ] Consent dialog with accept/reject
### 4.3 Plugin Configuration (~2-3 days)
- [ ] Plugin settings persistence
- [ ] Plugin-specific config UI
--- ---
## Recommended Order ## ✅ COMPLETED (Power Management)
### Session Today: Power Management ### ~~PRIORITY 1: Power Management~~ ✅ DONE (2025-11-26)
1. ✅ Plans A, B, C (cloud-init, PYTHONPATH, port conflict) - DONE - [x] `get_power_restrictions()` method implemented
2. ✅ Header widget system design - DONE - [x] API endpoint `/api/system/power/restrictions` working
3. ✅ Power management policy design - DONE - [x] Returns correct response when UPS not detected
4. **⏭️ NEXT: Implement Priority 1 (Power Management)** - [x] Returns correct response when UPS on AC
- [x] Returns correct response when UPS on battery
### Next Session: Build & Deploy - [x] Documentation updated
1. Test pi-gen build (Priority 3)
2. Deploy to Pi hardware (Priority 4)
3. Verify power management works
4. Test PM3 operations
### Future Session: Polish
1. Implement header widgets (Priority 2)
2. Add firmware flashing (Sprint 3)
3. Advanced features
--- ---
## Quick Implementation Guide ## 🎯 Recommended Implementation Order
### Step 1: Add Power Restrictions to UPS Manager ### ✅ Done (2026-03-03)
1. ~~HTTPS Settings UI~~ - Complete
2. ~~Plugin Hook Wiring~~ - Complete
3. ~~WebSocket Auth~~ - Complete
```bash ### Next Up
# Edit UPS manager 1. **JWT for REST endpoints** - Replace Basic Auth with proper tokens
nano app/backend/managers/ups_manager.py 2. **Login Page** - Full auth UI (currently only WS login prompt)
3. **Full Auth System** - Multi-user, RBAC
# Add get_power_restrictions() method
# See POWER_MANAGEMENT_POLICY.md lines 49-120
```
### Step 2: Add API Endpoint
```bash
# Edit system API
nano app/backend/api/system.py
# Add /power/restrictions endpoint
# See POWER_MANAGEMENT_POLICY.md lines 166-180
```
### Step 3: Test Locally
```bash
# Start backend
cd app/backend
python -m uvicorn main:app --reload
# Test endpoint
curl http://localhost:8000/api/system/power/restrictions
```
### Step 4: Update Docs
```bash
# Update PROJECT_STATUS.md
# Update README.md with power management info
```
--- ---
## Success Criteria ## 📁 Key Files Reference
### Priority 1 Complete When: ### HTTPS
- [x] Power management policy documented | File | Lines | Purpose |
- [ ] `get_power_restrictions()` method implemented |------|-------|---------|
- [ ] API endpoint `/api/system/power/restrictions` working | `scripts/generate-ssl-cert.sh` | 1-115 | EC P-256 certificate generation |
- [ ] Returns correct response when UPS not detected | `scripts/configure-nginx.sh` | 1-91 | nginx config switching |
- [ ] Returns correct response when UPS on AC | `nginx/dangerous-pi-https.conf` | 1-161 | TLS 1.2/1.3, HSTS, captive portal |
- [ ] Returns correct response when UPS on battery | `app/backend/api/system.py` | 578-794 | SSL API endpoints |
- [ ] Documentation updated | `app/frontend/app/routes/settings.tsx` | 381-394 | UI display (currently read-only) |
### Ready for Pi Testing When: ### Authentication
- [ ] Priority 1 complete | File | Purpose |
- [ ] Priority 3 complete (build tested) |------|---------|
- [ ] Services start correctly | `app/backend/api/auth.py` | Basic HTTP auth + token endpoints |
- [ ] Web interface accessible | `app/backend/api/token_store.py` | In-memory WS auth token store (24h TTL) |
- [ ] No Python import errors | `app/backend/config.py:54` | AUTH_ENABLED setting |
| `app/backend/websocket/routes.py` | WebSocket with token validation |
| `app/frontend/app/hooks/useWebSocket.ts` | WS manager with auth flow |
| `app/frontend/app/components/LoginPrompt.tsx` | Login modal for WS auth |
--- ### Plugins
| File | Purpose |
**Current Status**: Priority 1 design complete, implementation needed (~1.5 hours) |------|---------|
**Blocker**: Must implement before Pi deployment | `app/backend/managers/plugin_manager.py` | Full plugin framework (804 lines) |
**Next Action**: Implement `get_power_restrictions()` method | `app/backend/api/plugins.py` | 7 CRUD endpoints |
| `app/backend/services/pm3_service.py` | Triggers `pm3_command` hook |
| `app/backend/managers/update_manager.py` | Triggers `update_check` hook |
| `app/plugins/hello_world/main.py` | Example hook registration |
| `app/frontend/app/routes/settings.tsx` | Plugin management UI |

View File

@@ -6,12 +6,12 @@ Based on hardware testing with Proxmark3 Easy and `LED_ORDER=PM3EASY`:
### LED Configuration ### LED Configuration
| LED | Color | GPIO Pin | PWM Channel | Brightness | Physical Position | | LED | Color | GPIO Pin | PWM Channel | Brightness | Effects | Physical Position |
|-----|--------|----------|-------------|------------|-------------------| |-----|--------|----------|-------------|------------|---------|-------------------|
| A | Green | PA0 | PWM0 | ✅ 0-100% | 1st (leftmost) | | A | Green | PA0 | PWM0 | ✅ 0-100% | All | 1st (leftmost) |
| B | Blue | PA2 | PWM2 | ✅ 0-100% | 4th (rightmost) | | B | Blue | PA2 | PWM2 | ✅ 0-100% | All | 4th (rightmost) |
| C | Orange | PA9 | None | On/Off | 2nd | | C | Orange | PA9 | None | On/Off | Blink | 2nd |
| D | Red | PA8 | None | On/Off | 3rd | | D | Red | PA8 | None | On/Off | Blink | 3rd |
**Physical LED order (left to right):** Green, Orange, Red, Blue **Physical LED order (left to right):** Green, Orange, Red, Blue
@@ -40,18 +40,18 @@ From `include/config_gpio.h` with `LED_ORDER_PM3EASY`:
With default LED ordering (no `LED_ORDER_PM3EASY`): With default LED ordering (no `LED_ORDER_PM3EASY`):
| LED | Color | GPIO Pin | PWM Channel | Notes | | LED | Color | GPIO Pin | PWM Channel | Brightness | Effects |
|-----|---------|----------|-------------|-------| |-----|---------|----------|-------------|------------|---------|
| A | Orange | PA0 | PWM0 | ✅ Brightness capable | | A | Orange | PA0 | PWM0 | ✅ 0-100% | All |
| B | Green | PA8 | None | On/Off only | | B | Green | PA8 | None | On/Off | Blink |
| C | Red | PA9 | None | On/Off only | | C | Red | PA9 | None | On/Off | Blink |
| D | Red2 | PA2 | PWM2 | ✅ Brightness capable | | D | Red2 | PA2 | PWM2 | ✅ 0-100% | All |
**Note:** Only LEDs on PA0 and PA2 can use PWM brightness control on any PM3 variant. **Note:** Only LEDs on PA0 and PA2 can use PWM brightness control and PWM effects (pulse, fade) on any PM3 variant.
## Usage Examples ## Usage Examples
### Proxmark3 Easy ### Basic LED Control
```bash ```bash
# Green LED at 50% brightness # Green LED at 50% brightness
@@ -70,9 +70,45 @@ hw led --led d --toggle
hw led --led a,b --brightness 30 hw led --led a,b --brightness 30
``` ```
### LED Effects
Three effects are available: **pulse**, **fade**, and **blink**.
```bash
# Pulse effect (PWM LEDs only: A, B)
# Smoothly fades in and out
hw led --led a --pulse # 5 cycles @ 500ms default
hw led --led a,b --pulse --count 10 # 10 cycles
hw led --led a --pulse --speed 300 # Faster pulse (300ms cycle)
hw led --led b --pulse --count 0 # Infinite until button press
# Fade effect (PWM LEDs only: A, B)
# Starts at full brightness, fades to off
hw led --led a --fade # Default 500ms fade
hw led --led a,b --fade --speed 1000 # Slower 1-second fade
# Blink effect (ALL LEDs supported: A, B, C, D)
# Alternates on/off
hw led --led a,b,c,d --blink # All LEDs, 5 blinks
hw led --led c,d --blink --count 20 # Non-PWM LEDs work too
hw led --led all --blink --speed 200 # Fast blink (200ms cycle)
hw led --led a --blink --count 0 # Infinite until stopped
```
### Effect Parameters
| Parameter | Description | Range | Default |
|-----------|-------------|-------|---------|
| `--speed` | Cycle time in milliseconds | 50-10000 | 500 |
| `--count` | Number of effect cycles | 0-65535 | 5 |
**Note:** `--count 0` runs the effect indefinitely until interrupted by:
- Pressing the button on the Proxmark3 device
- Sending any other command
### Multi-Device Identification ### Multi-Device Identification
Use brightness levels to distinguish between multiple Proxmark3 devices: Use brightness levels and effects to distinguish between multiple Proxmark3 devices:
```bash ```bash
# Device 1: Dim green # Device 1: Dim green
@@ -81,9 +117,11 @@ hw led --led a --brightness 20
# Device 2: Bright blue # Device 2: Bright blue
hw led --led b --brightness 100 hw led --led b --brightness 100
# Device 3: Medium green + dim blue # Device 3: Pulsing green (easy to spot)
hw led --led a --brightness 60 hw led --led a --pulse --count 0
hw led --led b --brightness 30
# Device 4: Fast blinking all LEDs
hw led --led all --blink --speed 200 --count 0
``` ```
## Hardware Testing Notes ## Hardware Testing Notes
@@ -102,5 +140,6 @@ hw led --led b --brightness 30
--- ---
**Last Updated:** 2025-12-02 **Last Updated:** 2026-01-06
**Hardware:** Proxmark3 Easy with LED_ORDER=PM3EASY **Hardware:** Proxmark3 Easy with LED_ORDER=PM3EASY
**Effects Added:** pulse, fade, blink (v1.1)

View File

@@ -1,6 +1,6 @@
# Dangerous Pi - Project Status # Dangerous Pi - Project Status
**Last Updated**: 2026-01-04 **Last Updated**: 2026-03-03
## ✅ Phase 3 Complete - Hardware Testing Passed ## ✅ Phase 3 Complete - Hardware Testing Passed
@@ -12,6 +12,84 @@ All MVP features tested on actual hardware:
--- ---
## 📊 Feature Evaluation Summary (2026-03-03)
| Feature | Completion | Backend | Frontend | Notes |
|---------|------------|---------|----------|-------|
| **HTTPS** | ✅ 100% | ✅ Complete | ✅ Toggle + info | Done 2026-03-03 |
| **Authentication** | 40% | ✅ Basic + WS tokens | ✅ Login prompt | WebSocket protected |
| **Plugin System** | 85% | ✅ Complete + hooks wired | ✅ Complete | Remote install missing |
### 🔐 Security Gaps (Remaining)
1. **Single User Only**: Basic HTTP auth with hardcoded username, no multi-user support
2. **No Logout**: Active sessions cannot be revoked
3. **No JWT**: Still using Basic Auth for REST endpoints (tokens only for WS)
### ✅ HTTPS (100% Complete - Done 2026-03-03)
**Implemented:**
- SSL API endpoints fully working (`/api/system/ssl/info`, `/api/system/ssl/regenerate`)
- Certificate generation script with EC P-256 keys, 10-year validity (`scripts/generate-ssl-cert.sh`)
- nginx HTTPS config with TLS 1.2/1.3, HSTS, modern ciphers (`nginx/dangerous-pi-https.conf`)
- Captive portal detection for Android, iOS, Windows, macOS, Firefox, Kindle
- Systemd services for auto-generation (`dangerous-pi-ssl-gen.service`)
- nginx config switching script (`scripts/configure-nginx.sh`)
- Pi-gen integration (stage 05-https-support)
- Frontend displays HTTPS status badge in Settings
- Subject Alternative Names: 192.168.4.1, dangerous-pi.local, localhost
- ✅ Frontend Enable/Disable toggle (`POST /api/system/ssl/toggle`)
- ✅ Certificate regeneration button in UI
- ✅ SSL info display (expiration, SANs, SHA256 fingerprint)
### 🔑 Authentication (40% Complete)
**Implemented:**
- Basic HTTP authentication (`app/backend/api/auth.py`)
- `AUTH_ENABLED` environment variable toggle (default: false)
- Single username/password via env vars
- Conditional router protection when enabled
- ✅ WebSocket token-based auth (`app/backend/api/token_store.py`) - Done 2026-03-03
-`POST /api/auth/token` - exchange Basic Auth for WS token (24h TTL)
-`GET /api/auth/status` - public endpoint for auth status check
- ✅ WebSocket validates token query param, rejects with close code 4401
- ✅ Frontend LoginPrompt modal (`app/frontend/app/components/LoginPrompt.tsx`)
- ✅ WebSocket manager handles auth flow (token fetch, retry, auth_required state)
**Missing:**
- JWT token-based auth for REST endpoints
- Multi-user support
- User management
- Role-based access control (RBAC)
- Logout functionality
- Session revocation
**Estimated to Complete:** ~2-3 weeks (full system, JWT + multi-user + RBAC)
### 🔌 Plugin System (85% Complete)
**Implemented:**
- Plugin Manager: 804 lines, full lifecycle (load/enable/disable/unload)
- Hook system: `register_hook()` and `trigger_hook()` methods implemented
- Header widget system: severity levels (INFO/WARNING/ERROR/SUCCESS), dismissible, actions, expiration
- Hardware access layer: GPIO, I2C, SPI, Serial with permission enforcement
- 7 API endpoints: discover, list, info, enable, disable, load, unload
- Frontend UI: discovery button, plugin listing, enable/disable toggles, permission badges
- Hello World example plugin with registered hooks
- WebSocket broadcasting capability for plugins (requires `websocket` permission)
- ✅ Hook triggers wired in PM3 service (`pm3_command` hook) - Done 2026-03-03
- ✅ Hook triggers wired in Update manager (`update_check` hook) - Done 2026-03-03
**Missing:**
- Remote plugin installation (from GitHub releases)
- pip dependency management
- Permission consent dialog before enabling
**Estimated to Complete:**
- Remote installation: ~1-2 weeks
---
## ✅ Completed Features ## ✅ Completed Features
### Backend (Python + FastAPI) ### Backend (Python + FastAPI)
@@ -90,15 +168,21 @@ All MVP features tested on actual hardware:
- Guided workflow triggers via BLE - Guided workflow triggers via BLE
- For React Native mobile app integration - For React Native mobile app integration
**Plugin Framework (Complete):** **Plugin Framework (85% Complete - See Feature Evaluation):**
- ✅ Dynamic plugin loading/unloading - ✅ Dynamic plugin loading/unloading (804-line plugin manager)
- ✅ Plugin lifecycle management (load, enable, disable, unload) - ✅ Plugin lifecycle management (load, enable, disable, unload)
- ✅ Hook system for extensibility - ✅ Hook system: `register_hook()` and `trigger_hook()` fully implemented
-JSON-based metadata -Hook triggers wired in PM3 service and Update manager
- ✅ Header widget system with severity levels, actions, expiration
- ✅ Hardware access layer: GPIO, I2C, SPI, Serial with permission checks
- ✅ JSON-based metadata with permissions and dependencies
- ✅ Plugin discovery from directory - ✅ Plugin discovery from directory
- ✅ Enable/disable individual plugins - ✅ Enable/disable individual plugins
- ✅ Example "Hello World" plugin - ✅ Example "Hello World" plugin with registered hooks
- ✅ 7 Plugin API endpoints - ✅ 7 Plugin API endpoints (discover, list, info, enable, disable, load, unload)
- ✅ Frontend UI with discovery, listing, toggle controls, permission badges
- ❌ Remote plugin installation not implemented
- ❌ pip dependency management not implemented
**Proxmark3 Firmware Enhancements (Complete - 2025-12-02):** **Proxmark3 Firmware Enhancements (Complete - 2025-12-02):**
- ✅ Two-channel hardware PWM LED brightness control - ✅ Two-channel hardware PWM LED brightness control
@@ -175,9 +259,10 @@ All MVP features tested on actual hardware:
-**PowerWidget** - Real-time UPS/battery status in header -**PowerWidget** - Real-time UPS/battery status in header
-**ConnectDialog** - PM3 connection management -**ConnectDialog** - PM3 connection management
-**QRScanner** - HTML5-based QR code scanning -**QRScanner** - HTML5-based QR code scanning
-**LoginPrompt** - Modal login form for WebSocket auth
**Hooks:** **Hooks:**
-**useWebSocket** - Singleton WebSocket manager with event subscriptions -**useWebSocket** - Singleton WebSocket manager with event subscriptions and token auth
**Performance:** **Performance:**
- ✅ Server-side rendering (SSR) - ✅ Server-side rendering (SSR)
@@ -268,15 +353,17 @@ All MVP features tested on actual hardware:
### Medium Priority ### Medium Priority
6. **Authentication** 6. **Authentication** (40% Complete - See Feature Evaluation)
- Optional password protection - Basic HTTP auth (backend)
- Session cookies - WebSocket token auth with LoginPrompt
- Login page - JWT tokens needed for REST
- Multi-user support needed
- RBAC needed
7. **HTTPS Support** 7. **HTTPS Support** COMPLETE
- Self-signed certificate generation - Self-signed certificate generation
- Automatic cert creation on first boot - Automatic cert creation on first boot
- Toggle in settings - Frontend toggle and cert info display
8. **Advanced PM3 Features** 8. **Advanced PM3 Features**
- Waveform viewer with pan/zoom - Waveform viewer with pan/zoom
@@ -319,7 +406,7 @@ All MVP features tested on actual hardware:
- **Lines of Code**: ~2,500+ - **Lines of Code**: ~2,500+
- **Files**: 13+ - **Files**: 13+
- **Pages**: 5 (Dashboard, Commands, Settings, Logs, Updates) - **Pages**: 5 (Dashboard, Commands, Settings, Logs, Updates)
- **Components**: 4 (DeviceSelector, PowerWidget, ConnectDialog, QRScanner) - **Components**: 5 (DeviceSelector, PowerWidget, ConnectDialog, QRScanner, LoginPrompt)
- **Hooks**: 1 (useWebSocket) - **Hooks**: 1 (useWebSocket)
- **CSS**: 15KB (uncompressed) - **CSS**: 15KB (uncompressed)
- **Bundle Size**: ~120KB (estimated, gzipped) - **Bundle Size**: ~120KB (estimated, gzipped)
@@ -506,7 +593,8 @@ dangerous-pi/
│ │ │ ├── wifi.py # WiFi management │ │ │ ├── wifi.py # WiFi management
│ │ │ ├── updates.py # Update management │ │ │ ├── updates.py # Update management
│ │ │ ├── plugins.py # Plugin management │ │ │ ├── plugins.py # Plugin management
│ │ │ ── auth.py # Authentication │ │ │ ── auth.py # Authentication + token endpoints
│ │ │ └── token_store.py # In-memory WS auth tokens
│ │ ├── websocket/ # WebSocket real-time events │ │ ├── websocket/ # WebSocket real-time events
│ │ │ ├── manager.py # Connection manager │ │ │ ├── manager.py # Connection manager
│ │ │ ├── routes.py # WebSocket endpoint │ │ │ ├── routes.py # WebSocket endpoint
@@ -552,7 +640,8 @@ dangerous-pi/
│ │ │ │ ├── DeviceSelector.tsx # Multi-device selection │ │ │ │ ├── DeviceSelector.tsx # Multi-device selection
│ │ │ │ ├── PowerWidget.tsx # UPS/battery status │ │ │ │ ├── PowerWidget.tsx # UPS/battery status
│ │ │ │ ├── ConnectDialog.tsx # Connection management │ │ │ │ ├── ConnectDialog.tsx # Connection management
│ │ │ │ ── QRScanner.tsx # QR code scanning │ │ │ │ ── QRScanner.tsx # QR code scanning
│ │ │ │ └── LoginPrompt.tsx # WS auth login modal
│ │ │ ├── hooks/ # Custom React hooks │ │ │ ├── hooks/ # Custom React hooks
│ │ │ │ └── useWebSocket.ts # WebSocket singleton manager │ │ │ │ └── useWebSocket.ts # WebSocket singleton manager
│ │ │ ├── root.tsx # Root layout │ │ │ ├── root.tsx # Root layout
@@ -767,10 +856,12 @@ Want to add features? Follow these steps:
- [ ] Performance optimization on Pi Zero 2 W (ongoing) - [ ] Performance optimization on Pi Zero 2 W (ongoing)
- [ ] WiFi mode detection fix (reports "client" when in AP mode) - [ ] WiFi mode detection fix (reports "client" when in AP mode)
### Phase 4: Enhancement 📋 PLANNED ### Phase 4: Enhancement 🚧 IN PROGRESS
- [ ] Backup system - [ ] Backup system
- [ ] Authentication - [x] HTTPS Settings UI (toggle, cert info, regenerate) - Done 2026-03-03
- [ ] HTTPS support - [x] Plugin hook wiring (pm3_command, update_check) - Done 2026-03-03
- [x] WebSocket authentication (token-based) - Done 2026-03-03
- [ ] Full authentication (JWT, multi-user, RBAC)
- [ ] Additional plugins - [ ] Additional plugins
- [ ] Advanced PM3 features - [ ] Advanced PM3 features

View File

@@ -1,11 +1,13 @@
"""Authentication module for Dangerous Pi API.""" """Authentication module for Dangerous Pi API."""
import secrets import secrets
from fastapi import Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .. import config from .. import config
from .token_store import create_token
security = HTTPBasic() security = HTTPBasic()
router = APIRouter()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str: def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str:
@@ -61,3 +63,26 @@ def get_optional_auth(credentials: HTTPBasicCredentials = Depends(security)) ->
return verify_credentials(credentials) return verify_credentials(credentials)
except HTTPException: except HTTPException:
return None return None
@router.get("/status")
async def auth_status():
"""Check whether authentication is enabled.
This endpoint is always public so the frontend can determine
whether to prompt for credentials before connecting the WebSocket.
"""
return {"auth_enabled": config.AUTH_ENABLED}
@router.post("/token")
async def get_auth_token(username: str = Depends(verify_credentials)):
"""Exchange Basic Auth credentials for a WebSocket auth token.
The returned token should be passed as a query parameter when
connecting to the WebSocket endpoint: ws://host/ws/events?token=...
Tokens expire after 24 hours.
"""
token = create_token(username)
return {"token": token}

View File

@@ -384,6 +384,8 @@ async def get_power_restrictions():
class PiModelResponse(BaseModel): class PiModelResponse(BaseModel):
"""Pi model information response.""" """Pi model information response."""
model_config = {"protected_namespaces": ()} # Allow model_* field names
model: str model: str
model_short: str model_short: str
total_cores: int total_cores: int
@@ -793,6 +795,93 @@ async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
) )
class SSLToggleRequest(BaseModel):
"""Request to enable or disable HTTPS."""
enabled: bool
@router.post("/ssl/toggle")
async def toggle_https(request: SSLToggleRequest):
"""Enable or disable HTTPS.
Updates the HTTPS_ENABLED setting in the .env file and runs
configure-nginx.sh to switch nginx between HTTP and HTTPS configs.
Reloads nginx to apply the change immediately.
Args:
enabled: True to enable HTTPS, False to disable
Returns:
Success status and current HTTPS state
"""
import subprocess
import os
env_file = os.getenv("ENV_FILE", "/opt/dangerous-pi/.env")
configure_script = "/opt/dangerous-pi/scripts/configure-nginx.sh"
# Update .env file
try:
lines = []
found = False
if os.path.exists(env_file):
with open(env_file, "r") as f:
lines = f.readlines()
new_lines = []
for line in lines:
if line.strip().startswith("HTTPS_ENABLED="):
new_lines.append(f"HTTPS_ENABLED={'true' if request.enabled else 'false'}\n")
found = True
else:
new_lines.append(line)
if not found:
new_lines.append(f"HTTPS_ENABLED={'true' if request.enabled else 'false'}\n")
with open(env_file, "w") as f:
f.writelines(new_lines)
except PermissionError:
raise HTTPException(
status_code=500,
detail="Cannot write to .env file - check permissions"
)
# Update runtime config
from .. import config as app_config
app_config.HTTPS_ENABLED = request.enabled
# Run configure-nginx.sh to switch nginx config
nginx_configured = False
if os.path.exists(configure_script):
try:
result = subprocess.run(
[configure_script],
capture_output=True,
text=True,
timeout=15,
env={**os.environ, "HTTPS_ENABLED": "true" if request.enabled else "false"}
)
if result.returncode == 0:
# Reload nginx
subprocess.run(
["systemctl", "reload", "nginx"],
capture_output=True,
timeout=10
)
nginx_configured = True
except Exception:
pass # Non-fatal, .env was still updated
return {
"success": True,
"enabled": request.enabled,
"nginx_configured": nginx_configured,
"message": f"HTTPS {'enabled' if request.enabled else 'disabled'}"
}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Header Widgets API # Header Widgets API
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -0,0 +1,55 @@
"""In-memory token store for WebSocket authentication.
Tokens are short-lived (24h) and stored in memory. Since this is a
single-device appliance with typically one user, persistence is not needed.
Tokens are cleaned up lazily on create/validate calls.
"""
import secrets
from datetime import datetime, timezone, timedelta
_tokens: dict[str, dict] = {}
TOKEN_TTL_HOURS = 24
def create_token(username: str) -> str:
"""Create a new auth token for the given username.
Args:
username: The authenticated username
Returns:
URL-safe token string
"""
token = secrets.token_urlsafe(32)
_tokens[token] = {
"username": username,
"expires_at": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS),
}
_cleanup_expired()
return token
def validate_token(token: str) -> str | None:
"""Validate a token and return the associated username.
Args:
token: The token to validate
Returns:
Username if valid, None if invalid or expired
"""
entry = _tokens.get(token)
if not entry:
return None
if datetime.now(timezone.utc) > entry["expires_at"]:
del _tokens[token]
return None
return entry["username"]
def _cleanup_expired() -> None:
"""Remove expired tokens."""
now = datetime.now(timezone.utc)
expired = [t for t, e in _tokens.items() if now > e["expires_at"]]
for t in expired:
del _tokens[t]

View File

@@ -259,9 +259,13 @@ class BlueZGATTAdapter:
"""Handle unsubscription from characteristic notifications.""" """Handle unsubscription from characteristic notifications."""
logger.info("Client unsubscribed from %s", characteristic.uuid) logger.info("Client unsubscribed from %s", characteristic.uuid)
async def start(self) -> bool: async def start(self, retries: int = 3, retry_delay: float = 2.0) -> bool:
"""Start the BLE GATT server. """Start the BLE GATT server.
Args:
retries: Number of startup attempts (for boot timing issues)
retry_delay: Delay between retries in seconds
Returns: Returns:
True if started successfully, False otherwise True if started successfully, False otherwise
""" """
@@ -269,39 +273,57 @@ class BlueZGATTAdapter:
logger.warning("BLE server already running") logger.warning("BLE server already running")
return True return True
try: last_error = None
self._loop = asyncio.get_running_loop() for attempt in range(retries):
try:
self._loop = asyncio.get_running_loop()
# Create bless server # Create bless server
self.server = BlessServer(name=self.device_name, loop=self._loop) self.server = BlessServer(name=self.device_name, loop=self._loop)
# Set up callbacks (bless 0.3.0+ API) # Set up callbacks (bless 0.3.0+ API)
self.server.read_request_func = self._on_read self.server.read_request_func = self._on_read
self.server.write_request_func = self._on_write self.server.write_request_func = self._on_write
# Build and add GATT structure # Build and add GATT structure
gatt = self._build_gatt_dict() gatt = self._build_gatt_dict()
await self.server.add_gatt(gatt) await self.server.add_gatt(gatt)
# Start the server (begins advertising) # Start the server (begins advertising)
await self.server.start() await self.server.start()
# Start GATT server handlers # Start GATT server handlers
await self.gatt_server.start() await self.gatt_server.start()
# Register notification callbacks # Register notification callbacks
self._setup_notification_callbacks() self._setup_notification_callbacks()
self._is_running = True self._is_running = True
logger.info("BLE GATT server started, advertising as '%s'", self.device_name) logger.info("BLE GATT server started, advertising as '%s'", self.device_name)
return True return True
except Exception as e: except Exception as e:
logger.error("Failed to start BLE server: %s", e) last_error = e
import traceback if attempt < retries - 1:
traceback.print_exc() logger.warning(
self._is_running = False "BLE server start attempt %d/%d failed: %s, retrying in %.1fs...",
return False attempt + 1, retries, e, retry_delay
)
# Clean up failed server before retry
if self.server:
try:
await self.server.stop()
except Exception:
pass
self.server = None
await asyncio.sleep(retry_delay)
else:
logger.error("Failed to start BLE server after %d attempts: %s", retries, e)
import traceback
traceback.print_exc()
self._is_running = False
return False
def _setup_notification_callbacks(self): def _setup_notification_callbacks(self):
"""Set up notification callbacks for characteristics that support notify.""" """Set up notification callbacks for characteristics that support notify."""

View File

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from . import config from . import config
from .models.database import init_db from .models.database import init_db
from .api import health, pm3, system, wifi, updates, plugins from .api import health, pm3, system, wifi, updates, plugins
from .api.auth import verify_credentials from .api.auth import verify_credentials, router as auth_router
from .websocket import router as ws_router from .websocket import router as ws_router
from .websocket import notifications as ws_notifications from .websocket import notifications as ws_notifications
from .managers.update_manager import get_update_manager from .managers.update_manager import get_update_manager
@@ -201,6 +201,7 @@ app.include_router(system.router, prefix="/api/system", tags=["system"], depende
app.include_router(wifi.router, prefix="/api/wifi", tags=["wifi"], dependencies=auth_dependency) app.include_router(wifi.router, prefix="/api/wifi", tags=["wifi"], dependencies=auth_dependency)
app.include_router(updates.router, prefix="/api/updates", tags=["updates"], dependencies=auth_dependency) app.include_router(updates.router, prefix="/api/updates", tags=["updates"], dependencies=auth_dependency)
app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], dependencies=auth_dependency) app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"], dependencies=auth_dependency)
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(ws_router, prefix="/ws", tags=["websocket"]) app.include_router(ws_router, prefix="/ws", tags=["websocket"])
# Serve frontend static files if build directory exists # Serve frontend static files if build directory exists

View File

@@ -111,6 +111,13 @@ class UpdateManager:
"message": "No releases found" "message": "No releases found"
} }
# Trigger plugin hooks (non-critical)
try:
from .plugin_manager import get_plugin_manager
await get_plugin_manager().trigger_hook("update_check")
except Exception:
pass
# Compare versions # Compare versions
if self._is_newer_version(release.version, self._current_version): if self._is_newer_version(release.version, self._current_version):
self._latest_release = release self._latest_release = release

View File

@@ -16,7 +16,9 @@ from .i2c_driver import I2CFuelGaugeDriver
async def auto_detect_driver( async def auto_detect_driver(
pisugar_host: str = "127.0.0.1", pisugar_host: str = "127.0.0.1",
pisugar_port: int = 8423, pisugar_port: int = 8423,
prefer_native_i2c: bool = True prefer_native_i2c: bool = True,
i2c_retries: int = 5,
i2c_retry_delay: float = 1.0
) -> Tuple[Optional[UPSDriver], str]: ) -> Tuple[Optional[UPSDriver], str]:
"""Auto-detect available UPS hardware and return appropriate driver. """Auto-detect available UPS hardware and return appropriate driver.
@@ -29,6 +31,8 @@ async def auto_detect_driver(
pisugar_host: PiSugar server host for daemon detection (legacy) pisugar_host: PiSugar server host for daemon detection (legacy)
pisugar_port: PiSugar server port (legacy) pisugar_port: PiSugar server port (legacy)
prefer_native_i2c: If True, prefer native I2C driver over daemon prefer_native_i2c: If True, prefer native I2C driver over daemon
i2c_retries: Number of I2C detection retries (for boot timing)
i2c_retry_delay: Delay between I2C retries in seconds
Returns: Returns:
Tuple of (driver_instance or None, detection_message) Tuple of (driver_instance or None, detection_message)
@@ -37,13 +41,16 @@ async def auto_detect_driver(
# Try native PiSugar I2C first (no daemon overhead, minimal CPU) # Try native PiSugar I2C first (no daemon overhead, minimal CPU)
if prefer_native_i2c: if prefer_native_i2c:
print("UPS auto-detect: Trying PiSugar I2C (native)...") print(f"UPS auto-detect: Trying PiSugar I2C (native, {i2c_retries} retries)...")
detected, driver = await PiSugarI2CDriver.detect() detected, driver = await PiSugarI2CDriver.detect(
retries=i2c_retries,
retry_delay=i2c_retry_delay
)
if detected and driver: if detected and driver:
model = driver.get_model_name() model = driver.get_model_name()
print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}") print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}")
return (driver, f"Detected PiSugar UPS via I2C: {model}") return (driver, f"Detected PiSugar UPS via I2C: {model}")
print("UPS auto-detect: PiSugar I2C not detected") print("UPS auto-detect: PiSugar I2C not detected after all retries")
# Try PiSugar daemon (legacy fallback) # Try PiSugar daemon (legacy fallback)
print("UPS auto-detect: Trying PiSugar daemon...") print("UPS auto-detect: Trying PiSugar daemon...")

View File

@@ -103,7 +103,7 @@ class UPSManager:
print(f"Unknown UPS type '{ups_type}', will auto-detect") print(f"Unknown UPS type '{ups_type}', will auto-detect")
return None return None
async def initialize(self, startup_delay: float = 1.0) -> bool: async def initialize(self, startup_delay: float = 2.0) -> bool:
"""Initialize connection to UPS hardware. """Initialize connection to UPS hardware.
Args: Args:
@@ -124,15 +124,19 @@ class UPSManager:
self._status.error_message = "UPS disabled" self._status.error_message = "UPS disabled"
return False return False
# Small delay to ensure I2C bus is ready after boot # Delay to ensure I2C bus is ready after boot
# PiSugar and other I2C devices may not be ready immediately
if startup_delay > 0: if startup_delay > 0:
print(f"UPS: Waiting {startup_delay}s for I2C bus to settle...")
await asyncio.sleep(startup_delay) await asyncio.sleep(startup_delay)
# Run auto-detection # Run auto-detection with extended retries for boot scenarios
print("Auto-detecting UPS hardware...") print("Auto-detecting UPS hardware...")
driver, message = await auto_detect_driver( driver, message = await auto_detect_driver(
pisugar_host=config.UPS_PISUGAR_HOST, pisugar_host=config.UPS_PISUGAR_HOST,
pisugar_port=config.UPS_PISUGAR_PORT pisugar_port=config.UPS_PISUGAR_PORT,
i2c_retries=5,
i2c_retry_delay=1.0
) )
if driver: if driver:

View File

@@ -714,16 +714,27 @@ class WiFiManager:
if "successfully activated" in result.lower(): if "successfully activated" in result.lower():
logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}") logger.warning(f"[WiFi Connect] SUCCESS - connected to {ssid}")
# Verify we have an IP address # Verify we have a non-AP IP address
await asyncio.sleep(2) await asyncio.sleep(2)
ip_result = await self._run_command(f"ip addr show {interface}") ip_result = await self._run_command(f"ip addr show {interface}")
logger.warning(f"[WiFi Connect] IP result: {ip_result}") logger.warning(f"[WiFi Connect] IP result: {ip_result}")
if "inet " in ip_result and "192.168.4." not in ip_result:
# Extract all IP addresses and check for non-AP IPs
# AP uses 192.168.4.x subnet
ip_matches = re.findall(r'inet (\d+\.\d+\.\d+\.\d+)', ip_result)
client_ips = [ip for ip in ip_matches if not ip.startswith("192.168.4.")]
if client_ips:
logger.warning(f"[WiFi Connect] Found client IP(s): {client_ips}")
# Remove the AP static IP since we're in client mode now
await self._run_command(f"ip addr del 192.168.4.1/24 dev {interface}", check=False)
# Save client mode for boot persistence # Save client mode for boot persistence
self._current_mode = WiFiMode.CLIENT self._current_mode = WiFiMode.CLIENT
self._save_mode(WiFiMode.CLIENT) self._save_mode(WiFiMode.CLIENT)
logger.info(f"WiFi client mode saved for boot persistence") logger.info(f"WiFi client mode saved for boot persistence")
return True return True
else:
logger.warning(f"[WiFi Connect] No client IP found, only AP IPs: {ip_matches}")
# Connection failed # Connection failed
logger.warning(f"[WiFi Connect] FAILED - {result}") logger.warning(f"[WiFi Connect] FAILED - {result}")

View File

@@ -139,15 +139,22 @@ class PM3Service:
) )
) )
# 3. Execute command # 3. Trigger plugin hooks (non-critical)
try:
from ..managers.plugin_manager import get_plugin_manager
await get_plugin_manager().trigger_hook("pm3_command", command)
except Exception:
pass
# 4. Execute command
try: try:
result = await worker.execute_command(command) result = await worker.execute_command(command)
# 4. Update session activity # 5. Update session activity
if session_id: if session_id:
self.session_manager.update_activity(session_id, device_id) self.session_manager.update_activity(session_id, device_id)
# 5. Return result # 6. Return result
if result.success: if result.success:
return PM3ServiceResult( return PM3ServiceResult(
success=True, success=True,

View File

@@ -1,6 +1,8 @@
"""WebSocket endpoint routes.""" """WebSocket endpoint routes."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from .manager import ws_manager from .manager import ws_manager
from ..api.token_store import validate_token
from .. import config
router = APIRouter() router = APIRouter()
@@ -9,6 +11,11 @@ router = APIRouter()
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time event streaming. """WebSocket endpoint for real-time event streaming.
When AUTH_ENABLED=true, a valid token must be provided as a query
parameter: ws://host/ws/events?token=<token>
Tokens are obtained via POST /api/auth/token with Basic Auth.
Unauthorized connections are closed with code 4401.
Events include: Events include:
- connected: Initial connection confirmation - connected: Initial connection confirmation
- system_stats: CPU, memory, temperature updates (every 5s) - system_stats: CPU, memory, temperature updates (every 5s)
@@ -22,6 +29,15 @@ async def websocket_endpoint(websocket: WebSocket):
- update_available: New version available - update_available: New version available
- update_downloading: Download progress - update_downloading: Download progress
""" """
# Validate auth token when authentication is enabled
if config.AUTH_ENABLED:
token = websocket.query_params.get("token")
if not token or not validate_token(token):
# Must accept before sending close code so client receives it
await websocket.accept()
await websocket.close(code=4401, reason="Authentication required")
return
await ws_manager.connect(websocket) await ws_manager.connect(websocket)
try: try:
while True: while True:

View File

@@ -0,0 +1,94 @@
import { useState, type FormEvent } from "react";
import { getWebSocketManager } from "../hooks/useWebSocket";
export function LoginPrompt() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await getWebSocketManager().fetchToken(username, password);
} catch (err) {
setError(err instanceof Error ? err.message : "Authentication failed");
} finally {
setLoading(false);
}
};
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.75)",
backdropFilter: "blur(4px)",
}}
>
<div className="card" style={{ maxWidth: "360px", width: "100%", margin: "1rem" }}>
<div className="card-title" style={{ textAlign: "center", marginBottom: "1.5rem" }}>
Dangerous Pi
</div>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label htmlFor="login-user" style={{ display: "block", marginBottom: "0.25rem", fontSize: "0.875rem" }}>
Username
</label>
<input
id="login-user"
className="input"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
style={{ width: "100%" }}
/>
</div>
<div>
<label htmlFor="login-pass" style={{ display: "block", marginBottom: "0.25rem", fontSize: "0.875rem" }}>
Password
</label>
<input
id="login-pass"
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
style={{ width: "100%" }}
/>
</div>
{error && (
<div style={{ color: "var(--color-error, #ff4444)", fontSize: "0.875rem" }}>
{error}
</div>
)}
<button
type="submit"
className="btn-primary"
disabled={loading}
style={{ width: "100%", marginTop: "0.5rem" }}
>
{loading ? "Authenticating..." : "Log In"}
</button>
</form>
</div>
</div>
);
}

View File

@@ -6,11 +6,16 @@
* - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max) * - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max)
* - Component-level event subscriptions * - Component-level event subscriptions
* - Connection state management * - Connection state management
* - Token-based authentication when AUTH_ENABLED=true
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
// Connection states // Connection states
export type ConnectionState = "connecting" | "connected" | "disconnected"; export type ConnectionState =
| "connecting"
| "connected"
| "disconnected"
| "auth_required";
// Event message format from backend // Event message format from backend
interface WebSocketMessage { interface WebSocketMessage {
@@ -23,6 +28,9 @@ interface WebSocketMessage {
// Event handler type // Event handler type
type EventHandler = (data: Record<string, unknown>) => void; type EventHandler = (data: Record<string, unknown>) => void;
// Custom close code for auth failure
const WS_CLOSE_AUTH_REQUIRED = 4401;
// Singleton WebSocket manager // Singleton WebSocket manager
class WebSocketManager { class WebSocketManager {
private static instance: WebSocketManager | null = null; private static instance: WebSocketManager | null = null;
@@ -32,6 +40,8 @@ class WebSocketManager {
private reconnectAttempts = 0; private reconnectAttempts = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null; private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private state: ConnectionState = "disconnected"; private state: ConnectionState = "disconnected";
private token: string | null = null;
private authChecked = false;
// Backoff configuration // Backoff configuration
private readonly INITIAL_DELAY = 1000; // 1 second private readonly INITIAL_DELAY = 1000; // 1 second
@@ -56,10 +66,23 @@ class WebSocketManager {
const host = window.location.hostname; const host = window.location.hostname;
const port = window.location.port || (protocol === "wss:" ? "443" : "80"); const port = window.location.port || (protocol === "wss:" ? "443" : "80");
return `${protocol}//${host}:${port}/ws/events`; let url = `${protocol}//${host}:${port}/ws/events`;
if (this.token) {
url += `?token=${encodeURIComponent(this.token)}`;
}
return url;
} }
connect(): void { private getApiBaseUrl(): string {
if (typeof window === "undefined") {
return "http://localhost:8000";
}
const { protocol, hostname, port } = window.location;
const p = port || (protocol === "https:" ? "443" : "80");
return `${protocol}//${hostname}:${p}`;
}
async connect(): Promise<void> {
if ( if (
this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.OPEN ||
this.ws?.readyState === WebSocket.CONNECTING this.ws?.readyState === WebSocket.CONNECTING
@@ -67,6 +90,16 @@ class WebSocketManager {
return; return;
} }
// Check auth status on first connect attempt
if (!this.authChecked) {
this.authChecked = true;
const authRequired = await this.checkAuthRequired();
if (authRequired && !this.token) {
this.setState("auth_required");
return;
}
}
this.setState("connecting"); this.setState("connecting");
const url = this.getWebSocketUrl(); const url = this.getWebSocketUrl();
@@ -74,7 +107,7 @@ class WebSocketManager {
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log("[WS] Connected to", url); console.log("[WS] Connected to", url.replace(/token=[^&]+/, "token=***"));
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.setState("connected"); this.setState("connected");
}; };
@@ -93,6 +126,15 @@ class WebSocketManager {
this.ws.onclose = (event) => { this.ws.onclose = (event) => {
console.log(`[WS] Disconnected (code: ${event.code})`); console.log(`[WS] Disconnected (code: ${event.code})`);
this.ws = null; this.ws = null;
if (event.code === WS_CLOSE_AUTH_REQUIRED) {
// Auth required - clear stale token and prompt for login
this.token = null;
this.setState("auth_required");
// Do NOT auto-reconnect - wait for user to authenticate
return;
}
this.setState("disconnected"); this.setState("disconnected");
this.scheduleReconnect(); this.scheduleReconnect();
}; };
@@ -106,6 +148,49 @@ class WebSocketManager {
} }
} }
private async checkAuthRequired(): Promise<boolean> {
try {
const resp = await fetch(`${this.getApiBaseUrl()}/api/auth/status`);
if (resp.ok) {
const data = await resp.json();
return data.auth_enabled === true;
}
} catch {
// If we can't reach the server, proceed without auth
// (the WS connection will fail and retry anyway)
}
return false;
}
/**
* Fetch an auth token using Basic Auth credentials.
* On success, stores the token and initiates the WebSocket connection.
*
* @throws Error with message if authentication fails
*/
async fetchToken(username: string, password: string): Promise<void> {
const resp = await fetch(`${this.getApiBaseUrl()}/api/auth/token`, {
method: "POST",
headers: {
Authorization: "Basic " + btoa(`${username}:${password}`),
},
});
if (!resp.ok) {
if (resp.status === 401) {
throw new Error("Invalid username or password");
}
throw new Error(`Authentication failed (${resp.status})`);
}
const data = await resp.json();
this.token = data.token;
this.authChecked = true;
// Now connect with the token
await this.connect();
}
private scheduleReconnect(): void { private scheduleReconnect(): void {
// Only reconnect if there are still subscribers // Only reconnect if there are still subscribers
if (this.getTotalSubscriberCount() === 0) { if (this.getTotalSubscriberCount() === 0) {
@@ -218,6 +303,11 @@ class WebSocketManager {
} }
} }
// Export for use by LoginPrompt
export function getWebSocketManager(): WebSocketManager {
return WebSocketManager.getInstance();
}
/** /**
* Hook to subscribe to WebSocket events. * Hook to subscribe to WebSocket events.
* *
@@ -256,7 +346,7 @@ export function useWebSocketEvent(
/** /**
* Hook to get current WebSocket connection state. * Hook to get current WebSocket connection state.
* *
* @returns Current connection state: "connecting" | "connected" | "disconnected" * @returns Current connection state: "connecting" | "connected" | "disconnected" | "auth_required"
* *
* @example * @example
* ```tsx * ```tsx

View File

@@ -11,6 +11,8 @@ import { useState, useEffect } from "react";
import styles from "./styles.css?url"; import styles from "./styles.css?url";
import { PowerWidget } from "./components/PowerWidget"; import { PowerWidget } from "./components/PowerWidget";
import { HeaderWidgets } from "./components/HeaderWidgets"; import { HeaderWidgets } from "./components/HeaderWidgets";
import { LoginPrompt } from "./components/LoginPrompt";
import { useWebSocketState } from "./hooks/useWebSocket";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles }, { rel: "stylesheet", href: styles },
@@ -38,6 +40,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
export default function App() { export default function App() {
const location = useLocation(); const location = useLocation();
const wsState = useWebSocketState();
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark"); const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
// Initialize theme (default to dark, honor system preference if available) // Initialize theme (default to dark, honor system preference if available)
@@ -153,6 +156,8 @@ export default function App() {
} }
} }
`}</style> `}</style>
{wsState === "auth_required" && <LoginPrompt />}
</> </>
); );
} }

View File

@@ -8,12 +8,13 @@ const QRScanner = lazy(() => import("~/components/QRScanner"));
export async function loader() { export async function loader() {
try { try {
const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes] = await Promise.all([ const [configRes, wifiStatusRes, savedNetworksRes, cpuCoresRes, pluginsRes, sslInfoRes] = await Promise.all([
fetch("http://localhost:8000/api/system/config"), fetch("http://localhost:8000/api/system/config"),
fetch("http://localhost:8000/api/wifi/status"), fetch("http://localhost:8000/api/wifi/status"),
fetch("http://localhost:8000/api/wifi/saved"), fetch("http://localhost:8000/api/wifi/saved"),
fetch("http://localhost:8000/api/system/cpu/cores"), fetch("http://localhost:8000/api/system/cpu/cores"),
fetch("http://localhost:8000/api/plugins/list"), fetch("http://localhost:8000/api/plugins/list"),
fetch("http://localhost:8000/api/system/ssl/info"),
]); ]);
const config = await configRes.json(); const config = await configRes.json();
@@ -21,13 +22,15 @@ export async function loader() {
const savedNetworksData = await savedNetworksRes.json(); const savedNetworksData = await savedNetworksRes.json();
const cpuCores = await cpuCoresRes.json(); const cpuCores = await cpuCoresRes.json();
const plugins = pluginsRes.ok ? await pluginsRes.json() : []; const plugins = pluginsRes.ok ? await pluginsRes.json() : [];
const sslInfo = sslInfoRes.ok ? await sslInfoRes.json() : null;
return json({ return json({
config, config,
wifiStatus, wifiStatus,
savedNetworks: savedNetworksData.networks || [], savedNetworks: savedNetworksData.networks || [],
cpuCores, cpuCores,
plugins plugins,
sslInfo
}); });
} catch (error) { } catch (error) {
return json({ return json({
@@ -57,6 +60,7 @@ export async function loader() {
pi_model: null pi_model: null
}, },
plugins: [], plugins: [],
sslInfo: null,
}); });
} }
} }
@@ -183,6 +187,43 @@ export async function action({ request }: ActionFunctionArgs) {
} }
} }
if (action === "toggle_https") {
const enabled = formData.get("enabled") === "true";
try {
const response = await fetch("http://localhost:8000/api/system/ssl/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const result = await response.json();
if (result.success) {
return json({ success: true, message: result.message });
} else {
return json({ success: false, error: "Failed to toggle HTTPS" });
}
} catch (error) {
return json({ success: false, error: "Failed to toggle HTTPS" });
}
}
if (action === "regenerate_ssl") {
try {
const response = await fetch("http://localhost:8000/api/system/ssl/regenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reload_nginx: true }),
});
const result = await response.json();
if (result.success) {
return json({ success: true, message: "SSL certificate regenerated" });
} else {
return json({ success: false, error: "Failed to regenerate certificate" });
}
} catch (error) {
return json({ success: false, error: "Failed to regenerate certificate" });
}
}
if (action === "discover_plugins") { if (action === "discover_plugins") {
try { try {
const response = await fetch("http://localhost:8000/api/plugins/discover"); const response = await fetch("http://localhost:8000/api/plugins/discover");
@@ -233,7 +274,7 @@ export async function action({ request }: ActionFunctionArgs) {
} }
export default function Settings() { export default function Settings() {
const { config, wifiStatus, savedNetworks, cpuCores, plugins } = useLoaderData<typeof loader>(); const { config, wifiStatus, savedNetworks, cpuCores, plugins, sslInfo } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>(); const actionData = useActionData<typeof action>();
const navigation = useNavigation(); const navigation = useNavigation();
const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general"); const [activeSection, setActiveSection] = useState<"general" | "wifi" | "plugins" | "advanced" | "system">("general");
@@ -384,13 +425,34 @@ export default function Settings() {
<span className={`badge ${config.https_enabled ? "badge-success" : "badge-info"}`}> <span className={`badge ${config.https_enabled ? "badge-success" : "badge-info"}`}>
{config.https_enabled ? "Enabled" : "Disabled"} {config.https_enabled ? "Enabled" : "Disabled"}
</span> </span>
<button className="btn btn-secondary" disabled> <Form method="post" style={{ display: "inline" }}>
Configure <input type="hidden" name="_action" value="toggle_https" />
</button> <input type="hidden" name="enabled" value={config.https_enabled ? "false" : "true"} />
<button className={`btn ${config.https_enabled ? "btn-secondary" : "btn-primary"}`} type="submit" disabled={isSubmitting}>
{config.https_enabled ? "Disable" : "Enable"}
</button>
</Form>
<Form method="post" style={{ display: "inline" }}>
<input type="hidden" name="_action" value="regenerate_ssl" />
<button className="btn btn-secondary" type="submit" disabled={isSubmitting}>
Regenerate Cert
</button>
</Form>
</div> </div>
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}> <p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)" }}>
Use self-signed certificate for secure connections Use self-signed certificate for secure connections
</p> </p>
{sslInfo?.certificate_exists && (
<div style={{ fontSize: "0.75rem", marginTop: "var(--space-2)", display: "flex", flexDirection: "column", gap: "2px" }}>
<span className="text-muted">Expires: {sslInfo.not_after || "Unknown"}</span>
{sslInfo.san && <span className="text-muted">SANs: {sslInfo.san.join(", ")}</span>}
{sslInfo.fingerprint_sha256 && (
<span className="text-muted" style={{ fontFamily: "var(--font-mono)", fontSize: "0.65rem", wordBreak: "break-all" }}>
SHA256: {sslInfo.fingerprint_sha256}
</span>
)}
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -391,6 +391,9 @@ EOF
systemctl enable dangerous-pi-ap.service systemctl enable dangerous-pi-ap.service
# Ensure NetworkManager is enabled (pi-gen may not carry the default)
systemctl enable NetworkManager
# Ensure hostapd and dnsmasq start AFTER network is configured # Ensure hostapd and dnsmasq start AFTER network is configured
mkdir -p /etc/systemd/system/hostapd.service.d mkdir -p /etc/systemd/system/hostapd.service.d
cat > /etc/systemd/system/hostapd.service.d/override.conf << 'EOF' cat > /etc/systemd/system/hostapd.service.d/override.conf << 'EOF'

View File

@@ -1,2 +0,0 @@
# Remove this file to enable PiSugar installation
# PiSugar support is optional and disabled by default

View File

@@ -1,24 +1,17 @@
#!/bin/bash -e #!/bin/bash -e
# Configure HTTPS support for Dangerous Pi # Configure HTTPS support for Dangerous Pi
# Installs SSL certificate generation and nginx configuration switching # Sets up SSL certificate generation and nginx configuration switching
# Note: Scripts and configs are already in /opt/dangerous-pi/ from stage 03
echo "=== Setting up HTTPS support ===" echo "=== Setting up HTTPS support ==="
# Create SSL directory # Create SSL directory for certificates
mkdir -p /opt/dangerous-pi/ssl mkdir -p /opt/dangerous-pi/ssl
mkdir -p /opt/dangerous-pi/nginx
# Install certificate generation script # Set executable permissions on scripts (already copied by stage 03)
echo "Installing SSL certificate generation script..." echo "Setting script permissions..."
install -m 755 /opt/dangerous-pi/scripts/generate-ssl-cert.sh /opt/dangerous-pi/scripts/ chmod 755 /opt/dangerous-pi/scripts/generate-ssl-cert.sh
chmod 755 /opt/dangerous-pi/scripts/configure-nginx.sh
# Install nginx configuration switcher
echo "Installing nginx configuration switcher..."
install -m 755 /opt/dangerous-pi/scripts/configure-nginx.sh /opt/dangerous-pi/scripts/
# Copy HTTPS nginx configuration
echo "Installing HTTPS nginx configuration..."
cp /opt/dangerous-pi/nginx/dangerous-pi-https.conf /opt/dangerous-pi/nginx/
# Install systemd service for first-boot certificate generation # Install systemd service for first-boot certificate generation
echo "Installing SSL certificate generation service..." echo "Installing SSL certificate generation service..."

View File

@@ -0,0 +1,86 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Dangerous Pi <dangerous-pi@users.noreply.github.com>
Date: Tue, 11 Feb 2026 00:00:00 +0000
Subject: [PATCH] client: detect booster board / LC tank loading during hw tune
When a booster board or other LC tank circuit is installed on a PM3
Easy (non-RDV4), the HF antenna voltage during `hw tune` drops into
the 2550-2750 mV range. Previously this was reported as "unusable"
with no Q-factor calculation. This change detects the loaded state
and warns the user about the likely cause.
The detection only fires on non-RDV4 devices since the voltage range
is calibrated for the PM3 Easy voltage divider (11:1, vdd_other=5400).
On RDV4 (42.67:1 divider), the same mV reading has different meaning.
---
client/src/cmdhw.c | 27 ++++++++++++++++++++++-----
1 file changed, 22 insertions(+), 5 deletions(-)
diff --git a/client/src/cmdhw.c b/client/src/cmdhw.c
--- a/client/src/cmdhw.c
+++ b/client/src/cmdhw.c
@@ -909,6 +909,8 @@
#define LF_MARGINAL_V 10000
#define HF_UNUSABLE_V 3000
#define HF_MARGINAL_V 5000
+#define HF_BOOSTER_LOW 2550 // lower bound of HF booster/LC tank loading range (mV)
+#define HF_BOOSTER_HIGH 2750 // upper bound of HF booster/LC tank loading range (mV)
#define ANTENNA_ERROR 1.00 // current algo has 3% error margin.
PrintAndLogEx(NORMAL, "");
@@ -1055,6 +1057,10 @@
memset(judgement, 0, sizeof(judgement));
+ bool hf_booster_detected = (package->v_hf >= HF_BOOSTER_LOW
+ && package->v_hf <= HF_BOOSTER_HIGH
+ && !IfPm3Rdv4Fw());
+
PrintAndLogEx(SUCCESS, "");
PrintAndLogEx(SUCCESS, "Approx. Q factor measurement");
@@ -1062,16 +1068,24 @@
// Q measure with Vlr=Q*(2*Vdd/pi)
double hfq = (double)package->v_hf * 3.14 / 2 / vdd;
PrintAndLogEx(SUCCESS, "Peak voltage.......... " _YELLOW_("%.1lf"), hfq);
- }
-
- if (package->v_hf < HF_UNUSABLE_V)
+ } else if (hf_booster_detected) {
+ PrintAndLogEx(SUCCESS, "Your HF antenna measurement shows");
+ PrintAndLogEx(SUCCESS, "low voltage that is consistent");
+ PrintAndLogEx(SUCCESS, "with the installation of a booster");
+ PrintAndLogEx(SUCCESS, "board. If you do not have a");
+ PrintAndLogEx(SUCCESS, "booster board installed, either");
+ PrintAndLogEx(SUCCESS, "your antenna is malfunctioning or");
+ PrintAndLogEx(SUCCESS, "you have a tag on the HF antenna.");
+ }
+
+ if (hf_booster_detected)
+ snprintf(judgement, sizeof(judgement), _YELLOW_("loaded"));
+ else if (package->v_hf < HF_UNUSABLE_V)
snprintf(judgement, sizeof(judgement), _RED_("unusable"));
else if (package->v_hf < HF_MARGINAL_V)
snprintf(judgement, sizeof(judgement), _YELLOW_("marginal"));
else
snprintf(judgement, sizeof(judgement), _GREEN_("ok"));
- PrintAndLogEx((package->v_hf < HF_UNUSABLE_V) ? WARNING : SUCCESS, "HF antenna ( %s )", judgement);
+ PrintAndLogEx((package->v_hf < HF_UNUSABLE_V && !hf_booster_detected) ? WARNING : SUCCESS, "HF antenna ( %s )", judgement);
+
+ if (hf_booster_detected) {
+ PrintAndLogEx(NORMAL, "");
+ }
// graph LF measurements
// even here, these values has 3% error.
@@ -1103,6 +1117,9 @@
PrintAndLogEx(NORMAL, "");
PrintAndLogEx(INFO, "Q factor must be measured without tag on the antenna");
+ if (hf_booster_detected) {
+ PrintAndLogEx(INFO, "Booster board detection range: %.2f - %.2f V", HF_BOOSTER_LOW / 1000.0, HF_BOOSTER_HIGH / 1000.0);
+ }
PrintAndLogEx(NORMAL, "");
return PM3_SUCCESS;
}

View File

@@ -9,6 +9,11 @@ index 88ce070..cbeb7d2 100644
+ case CMD_LED_CONTROL: { + case CMD_LED_CONTROL: {
+ payload_led_control_t *led_data = (payload_led_control_t *)packet->data.asBytes; + payload_led_control_t *led_data = (payload_led_control_t *)packet->data.asBytes;
+ +
+ // Check for extended struct (8 bytes) vs legacy (3 bytes)
+ bool extended = (packet->length >= 8);
+ uint16_t speed_ms = extended ? led_data->speed_ms : 500;
+ uint16_t count = extended ? led_data->count : 5;
+
+ if (led_data->action == 0) { + if (led_data->action == 0) {
+ // Turn off + // Turn off
+ if (led_data->led & LED_A) { + if (led_data->led & LED_A) {
@@ -65,6 +70,31 @@ index 88ce070..cbeb7d2 100644
+ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0); + reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0);
+ break; + break;
+ } + }
+
+ } else if (led_data->action == 4) {
+ // PULSE effect (PWM LEDs only)
+ if (led_data->led & ~(LED_A | LED_B)) {
+ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0);
+ break;
+ }
+ led_effect_pulse(led_data->led, speed_ms, count);
+
+ } else if (led_data->action == 5) {
+ // FADE effect (PWM LEDs only)
+ if (led_data->led & ~(LED_A | LED_B)) {
+ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0);
+ break;
+ }
+ led_effect_fade(led_data->led, speed_ms);
+
+ } else if (led_data->action == 6) {
+ // BLINK effect (all LEDs supported)
+ led_effect_blink(led_data->led, speed_ms, count);
+
+ } else {
+ // Unknown action
+ reply_ng(CMD_LED_CONTROL, PM3_EINVARG, NULL, 0);
+ break;
+ } + }
+ +
+ reply_ng(CMD_LED_CONTROL, PM3_SUCCESS, NULL, 0); + reply_ng(CMD_LED_CONTROL, PM3_SUCCESS, NULL, 0);
@@ -224,6 +254,157 @@ index 0df8935..6d8246f 100644
+ pwm2_initialized = false; + pwm2_initialized = false;
+ } + }
+} +}
+
+// ============================================================================
+// LED Effect Functions (firmware-side animations)
+// ============================================================================
+
+static volatile bool led_effect_stop_requested = false;
+
+void led_effects_stop(void) {
+ led_effect_stop_requested = true;
+}
+
+// Check if effect should stop (button press, USB data, or stop flag)
+static bool should_stop_effect(void) {
+ WDT_HIT();
+ return led_effect_stop_requested || BUTTON_PRESS() || data_available();
+}
+
+// PULSE: Fade in and out continuously (PWM LEDs only: A, B)
+void led_effect_pulse(uint8_t led_mask, uint16_t speed_ms, uint16_t count) {
+ led_effect_stop_requested = false;
+
+ // Only LED_A and LED_B support PWM
+ uint8_t pwm_mask = led_mask & (LED_A | LED_B);
+ if (pwm_mask == 0) return;
+
+ // Default speed if 0
+ if (speed_ms == 0) speed_ms = 500;
+
+ // Calculate steps: 10ms per step, speed_ms/2 per half-cycle
+ uint16_t step_delay = 10;
+ uint16_t steps = speed_ms / step_delay / 2;
+ if (steps < 1) steps = 1;
+ if (steps > 50) steps = 50;
+
+ uint16_t cycles_done = 0;
+ bool infinite = (count == 0);
+
+ while (infinite || cycles_done < count) {
+ // Fade in (0 -> 100)
+ for (uint16_t i = 0; i <= steps; i++) {
+ if (should_stop_effect()) goto pulse_cleanup;
+ uint8_t brightness = (i * 100) / steps;
+ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, brightness);
+ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, brightness);
+ SpinDelay(step_delay);
+ }
+
+ // Fade out (100 -> 0)
+ for (uint16_t i = steps; i > 0; i--) {
+ if (should_stop_effect()) goto pulse_cleanup;
+ uint8_t brightness = (i * 100) / steps;
+ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, brightness);
+ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, brightness);
+ SpinDelay(step_delay);
+ }
+
+ cycles_done++;
+ }
+
+pulse_cleanup:
+ // Disable PWM and turn off LEDs
+ if (pwm_mask & LED_A) { led_pwm_disable(LED_A); LED_A_OFF(); }
+ if (pwm_mask & LED_B) { led_pwm_disable(LED_B); LED_B_OFF(); }
+ led_effect_stop_requested = false;
+}
+
+// FADE: Full brightness then fade out once (PWM LEDs only: A, B)
+void led_effect_fade(uint8_t led_mask, uint16_t speed_ms) {
+ led_effect_stop_requested = false;
+
+ uint8_t pwm_mask = led_mask & (LED_A | LED_B);
+ if (pwm_mask == 0) return;
+
+ if (speed_ms == 0) speed_ms = 500;
+
+ uint16_t step_delay = 10;
+ uint16_t steps = speed_ms / step_delay;
+ if (steps < 1) steps = 1;
+ if (steps > 100) steps = 100;
+
+ // Start at full brightness
+ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, 100);
+ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, 100);
+
+ // Fade out
+ for (uint16_t i = steps; i > 0; i--) {
+ if (should_stop_effect()) goto fade_cleanup;
+ uint8_t brightness = (i * 100) / steps;
+ if (pwm_mask & LED_A) led_set_pwm_brightness(LED_A, brightness);
+ if (pwm_mask & LED_B) led_set_pwm_brightness(LED_B, brightness);
+ SpinDelay(step_delay);
+ }
+
+fade_cleanup:
+ if (pwm_mask & LED_A) { led_pwm_disable(LED_A); LED_A_OFF(); }
+ if (pwm_mask & LED_B) { led_pwm_disable(LED_B); LED_B_OFF(); }
+ led_effect_stop_requested = false;
+}
+
+// BLINK: Alternating on/off (works with ALL LEDs including C, D)
+void led_effect_blink(uint8_t led_mask, uint16_t speed_ms, uint16_t count) {
+ led_effect_stop_requested = false;
+
+ if (led_mask == 0) return;
+ if (speed_ms == 0) speed_ms = 500;
+
+ // Disable any PWM first (use GPIO toggling)
+ if (led_mask & LED_A) led_pwm_disable(LED_A);
+ if (led_mask & LED_B) led_pwm_disable(LED_B);
+
+ uint16_t half_cycle = speed_ms / 2;
+ if (half_cycle > 1390) half_cycle = 1390; // SpinDelay limit
+
+ uint16_t cycles_done = 0;
+ bool infinite = (count == 0);
+
+ // Start with LEDs off
+ if (led_mask & LED_A) LED_A_OFF();
+ if (led_mask & LED_B) LED_B_OFF();
+ if (led_mask & LED_C) LED_C_OFF();
+ if (led_mask & LED_D) LED_D_OFF();
+
+ while (infinite || cycles_done < count) {
+ if (should_stop_effect()) break;
+
+ // Toggle on
+ if (led_mask & LED_A) LED_A_ON();
+ if (led_mask & LED_B) LED_B_ON();
+ if (led_mask & LED_C) LED_C_ON();
+ if (led_mask & LED_D) LED_D_ON();
+
+ SpinDelay(half_cycle);
+ if (should_stop_effect()) break;
+
+ // Toggle off
+ if (led_mask & LED_A) LED_A_OFF();
+ if (led_mask & LED_B) LED_B_OFF();
+ if (led_mask & LED_C) LED_C_OFF();
+ if (led_mask & LED_D) LED_D_OFF();
+
+ SpinDelay(half_cycle);
+ cycles_done++;
+ }
+
+ // Ensure LEDs are off when done
+ if (led_mask & LED_A) LED_A_OFF();
+ if (led_mask & LED_B) LED_B_OFF();
+ if (led_mask & LED_C) LED_C_OFF();
+ if (led_mask & LED_D) LED_D_OFF();
+ led_effect_stop_requested = false;
+}
diff --git a/armsrc/util.h b/armsrc/util.h diff --git a/armsrc/util.h b/armsrc/util.h
index fd99ac7..d558dda 100644 index fd99ac7..d558dda 100644
--- a/armsrc/util.h --- a/armsrc/util.h
@@ -234,6 +415,12 @@ index fd99ac7..d558dda 100644
+void led_set_pwm_brightness(uint8_t led, uint8_t brightness); +void led_set_pwm_brightness(uint8_t led, uint8_t brightness);
+void led_pwm_disable(uint8_t led); +void led_pwm_disable(uint8_t led);
+
+// LED effect functions (firmware-side animations)
+void led_effect_pulse(uint8_t led_mask, uint16_t speed_ms, uint16_t count);
+void led_effect_fade(uint8_t led_mask, uint16_t speed_ms);
+void led_effect_blink(uint8_t led_mask, uint16_t speed_ms, uint16_t count);
+void led_effects_stop(void);
+ +
#endif #endif
diff --git a/client/src/cmdhw.c b/client/src/cmdhw.c diff --git a/client/src/cmdhw.c b/client/src/cmdhw.c
@@ -247,13 +434,18 @@ index 9bbdc98..ad877b0 100644
+static int CmdLed(const char *Cmd) { +static int CmdLed(const char *Cmd) {
+ CLIParserContext *ctx; + CLIParserContext *ctx;
+ CLIParserInit(&ctx, "hw led", + CLIParserInit(&ctx, "hw led",
+ "Control Proxmark3 LEDs with optional PWM brightness for LED A and B", + "Control Proxmark3 LEDs with optional PWM brightness and effects.\n"
+ "hw led --led a --on --> Turn on LED A\n" + "LED A (green) and B (blue) support PWM effects (pulse, fade, brightness).\n"
+ "hw led --led b --off --> Turn off LED B\n" + "All LEDs support blink effect.",
+ "hw led --led a,b --toggle --> Toggle LEDs A and B\n" + "hw led --led a --on --> Turn on LED A\n"
+ "hw led --led a --brightness 50 --> Set LED A to 50%% brightness (PWM)\n" + "hw led --led b --off --> Turn off LED B\n"
+ "hw led --led b --brightness 75 --> Set LED B to 75%% brightness (PWM)\n" + "hw led --led a,b --toggle --> Toggle LEDs A and B\n"
+ "hw led --led a,b --brightness 30 --> Set both LEDs A and B to 30%% brightness"); + "hw led --led a --brightness 50 --> Set LED A to 50%% brightness (PWM)\n"
+ "hw led --led a --pulse --> Pulse LED A (5 cycles, 500ms)\n"
+ "hw led --led a --pulse --count 10 --speed 300 --> Pulse 10 times, 300ms cycle\n"
+ "hw led --led b --fade --> Fade LED B out once\n"
+ "hw led --led a,b,c,d --blink --> Blink all LEDs\n"
+ "hw led --led c,d --blink --count 20 --speed 200 --> Fast blink non-PWM LEDs");
+ +
+ void *argtable[] = { + void *argtable[] = {
+ arg_param_begin, + arg_param_begin,
@@ -262,6 +454,11 @@ index 9bbdc98..ad877b0 100644
+ arg_lit0(NULL, "off", "Turn LED off"), + arg_lit0(NULL, "off", "Turn LED off"),
+ arg_lit0(NULL, "toggle", "Toggle LED"), + arg_lit0(NULL, "toggle", "Toggle LED"),
+ arg_int0(NULL, "brightness", "<0-100>", "LED brightness percentage (PWM, LED A and B only)"), + arg_int0(NULL, "brightness", "<0-100>", "LED brightness percentage (PWM, LED A and B only)"),
+ arg_lit0(NULL, "pulse", "Pulse effect - fade in/out (PWM LEDs only)"),
+ arg_lit0(NULL, "fade", "Fade effect - fade out from full (PWM LEDs only)"),
+ arg_lit0(NULL, "blink", "Blink effect - on/off alternating (all LEDs)"),
+ arg_int0(NULL, "speed", "<ms>", "Effect cycle time in milliseconds (default 500)"),
+ arg_int0(NULL, "count", "<n>", "Number of effect cycles (default 5, 0=infinite)"),
+ arg_param_end + arg_param_end
+ }; + };
+ CLIExecWithReturn(ctx, Cmd, argtable, false); + CLIExecWithReturn(ctx, Cmd, argtable, false);
@@ -273,11 +470,17 @@ index 9bbdc98..ad877b0 100644
+ bool off = arg_get_lit(ctx, 3); + bool off = arg_get_lit(ctx, 3);
+ bool toggle = arg_get_lit(ctx, 4); + bool toggle = arg_get_lit(ctx, 4);
+ int brightness = arg_get_int_def(ctx, 5, -1); + int brightness = arg_get_int_def(ctx, 5, -1);
+ bool pulse = arg_get_lit(ctx, 6);
+ bool fade = arg_get_lit(ctx, 7);
+ bool blink = arg_get_lit(ctx, 8);
+ int speed = arg_get_int_def(ctx, 9, 500);
+ int count = arg_get_int_def(ctx, 10, 5);
+ CLIParserFree(ctx); + CLIParserFree(ctx);
+ +
+ // Validate only one action + // Count how many actions specified
+ if ((on + off + toggle + (brightness >= 0)) != 1) { + int action_count = on + off + toggle + (brightness >= 0) + pulse + fade + blink;
+ PrintAndLogEx(ERR, "Specify exactly one action: --on, --off, --toggle, or --brightness"); + if (action_count != 1) {
+ PrintAndLogEx(ERR, "Specify exactly one action: --on, --off, --toggle, --brightness, --pulse, --fade, or --blink");
+ return PM3_EINVARG; + return PM3_EINVARG;
+ } + }
+ +
@@ -285,18 +488,21 @@ index 9bbdc98..ad877b0 100644
+ uint8_t led_mask = 0; + uint8_t led_mask = 0;
+ char *token = strtok(led_str, ","); + char *token = strtok(led_str, ",");
+ while (token != NULL) { + while (token != NULL) {
+ if (strcmp(token, "a") == 0 || strcmp(token, "A") == 0) { + // Trim leading whitespace
+ while (*token == ' ') token++;
+
+ if (strcasecmp(token, "a") == 0) {
+ led_mask |= 1; // LED_A + led_mask |= 1; // LED_A
+ } else if (strcmp(token, "b") == 0 || strcmp(token, "B") == 0) { + } else if (strcasecmp(token, "b") == 0) {
+ led_mask |= 2; // LED_B + led_mask |= 2; // LED_B
+ } else if (strcmp(token, "c") == 0 || strcmp(token, "C") == 0) { + } else if (strcasecmp(token, "c") == 0) {
+ led_mask |= 4; // LED_C + led_mask |= 4; // LED_C
+ } else if (strcmp(token, "d") == 0 || strcmp(token, "D") == 0) { + } else if (strcasecmp(token, "d") == 0) {
+ led_mask |= 8; // LED_D + led_mask |= 8; // LED_D
+ } else if (strcmp(token, "all") == 0 || strcmp(token, "ALL") == 0) { + } else if (strcasecmp(token, "all") == 0) {
+ led_mask = 15; // All LEDs + led_mask = 15; // All LEDs
+ } else { + } else {
+ PrintAndLogEx(ERR, "Invalid LED: %s (use a, b, c, d, or all)", token); + PrintAndLogEx(ERR, "Invalid LED: '%s' (use a, b, c, d, or all)", token);
+ return PM3_EINVARG; + return PM3_EINVARG;
+ } + }
+ token = strtok(NULL, ","); + token = strtok(NULL, ",");
@@ -307,49 +513,108 @@ index 9bbdc98..ad877b0 100644
+ return PM3_EINVARG; + return PM3_EINVARG;
+ } + }
+ +
+ // Validate brightness + // Validate PWM-only actions (brightness, pulse, fade)
+ if (brightness >= 0) { + if ((brightness >= 0 || pulse || fade) && (led_mask & ~0x03)) {
+ if (brightness > 100) { + PrintAndLogEx(ERR, "PWM effects (pulse, fade, brightness) only supported for LED A and B");
+ PrintAndLogEx(ERR, "Brightness must be 0-100"); + return PM3_EINVARG;
+ return PM3_EINVARG;
+ }
+ // Check if only LED_A and/or LED_B selected for PWM
+ if (led_mask & ~0x03) { // If any bit other than LED_A or LED_B is set
+ PrintAndLogEx(ERR, "PWM brightness only supported for LED A and B");
+ return PM3_EINVARG;
+ }
+ } + }
+ +
+ // Build payload + // Validate brightness range
+ if (brightness > 100) {
+ PrintAndLogEx(ERR, "Brightness must be 0-100");
+ return PM3_EINVARG;
+ }
+
+ // Validate speed range
+ if (speed < 50 || speed > 10000) {
+ PrintAndLogEx(ERR, "Speed must be between 50 and 10000 ms");
+ return PM3_EINVARG;
+ }
+
+ // Validate count
+ if (count < 0 || count > 65535) {
+ PrintAndLogEx(ERR, "Count must be 0-65535 (0 = infinite)");
+ return PM3_EINVARG;
+ }
+
+ // Build extended payload (8 bytes)
+ struct { + struct {
+ uint8_t led; + uint8_t led;
+ uint8_t action; + uint8_t action;
+ uint8_t brightness; + uint8_t brightness;
+ uint8_t reserved;
+ uint16_t speed_ms;
+ uint16_t count;
+ } PACKED payload; + } PACKED payload;
+ +
+ payload.led = led_mask; + payload.led = led_mask;
+ if (on) { + payload.brightness = (brightness >= 0) ? brightness : 100;
+ payload.action = 1; + payload.reserved = 0;
+ } else if (off) { + payload.speed_ms = speed;
+ payload.count = count;
+
+ // Determine action code
+ if (off) {
+ payload.action = 0; + payload.action = 0;
+ } else if (on) {
+ payload.action = 1;
+ } else if (toggle) { + } else if (toggle) {
+ payload.action = 2; + payload.action = 2;
+ } else { + } else if (brightness >= 0) {
+ payload.action = 3; // PWM + payload.action = 3;
+ } else if (pulse) {
+ payload.action = 4;
+ } else if (fade) {
+ payload.action = 5;
+ } else if (blink) {
+ payload.action = 6;
+ }
+
+ // Print feedback for effects
+ if (pulse || fade || blink) {
+ const char *effect_name = pulse ? "pulse" : (fade ? "fade" : "blink");
+ PrintAndLogEx(INFO, "LED %s: LEDs=%s%s%s%s, speed=%dms, count=%d",
+ effect_name,
+ (led_mask & 1) ? "A" : "",
+ (led_mask & 2) ? "B" : "",
+ (led_mask & 4) ? "C" : "",
+ (led_mask & 8) ? "D" : "",
+ payload.speed_ms,
+ payload.count);
+
+ if (count == 0) {
+ PrintAndLogEx(INFO, "Running indefinitely. Press button on device or send any command to stop.");
+ }
+ } + }
+ payload.brightness = (brightness >= 0) ? brightness : 0;
+ +
+ clearCommandBuffer(); + clearCommandBuffer();
+ SendCommandNG(CMD_LED_CONTROL, (uint8_t *)&payload, sizeof(payload)); + SendCommandNG(CMD_LED_CONTROL, (uint8_t *)&payload, sizeof(payload));
+ PacketResponseNG resp; +
+ if (WaitForResponseTimeout(CMD_LED_CONTROL, &resp, 1000) == false) { + // Calculate timeout based on effect duration
+ PrintAndLogEx(WARNING, "command execution time out"); + uint32_t timeout = 2000;
+ return PM3_ETIMEOUT; + if (pulse || blink) {
+ if (count == 0) {
+ timeout = 0; // No wait for infinite effects
+ } else {
+ timeout = (count * speed) + 2000;
+ if (timeout > 120000) timeout = 120000; // Max 2 minutes
+ }
+ } else if (fade) {
+ timeout = speed + 2000;
+ } + }
+ if (resp.status != PM3_SUCCESS) { +
+ PrintAndLogEx(ERR, "LED control command failed"); + if (timeout > 0) {
+ return resp.status; + PacketResponseNG resp;
+ if (WaitForResponseTimeout(CMD_LED_CONTROL, &resp, timeout) == false) {
+ PrintAndLogEx(WARNING, "command execution time out");
+ return PM3_ETIMEOUT;
+ }
+ if (resp.status != PM3_SUCCESS) {
+ PrintAndLogEx(ERR, "LED control command failed");
+ return resp.status;
+ }
+ } + }
+
+ return PM3_SUCCESS; + return PM3_SUCCESS;
+} +}
+ +
@@ -360,7 +625,7 @@ index 9bbdc98..ad877b0 100644
{"connect", CmdConnect, AlwaysAvailable, "Connect to the device via serial port"}, {"connect", CmdConnect, AlwaysAvailable, "Connect to the device via serial port"},
{"dbg", CmdDbg, IfPm3Present, "Set device side debug level"}, {"dbg", CmdDbg, IfPm3Present, "Set device side debug level"},
{"fpgaoff", CmdFPGAOff, IfPm3Present, "Turn off FPGA on device"}, {"fpgaoff", CmdFPGAOff, IfPm3Present, "Turn off FPGA on device"},
+ {"led", CmdLed, IfPm3Present, "Control LEDs with optional PWM brightness"}, + {"led", CmdLed, IfPm3Present, "Control LEDs with PWM brightness and effects"},
{"lcd", CmdLCD, IfPm3Lcd, "Send command/data to LCD"}, {"lcd", CmdLCD, IfPm3Lcd, "Send command/data to LCD"},
{"lcdreset", CmdLCDReset, IfPm3Lcd, "Hardware reset LCD"}, {"lcdreset", CmdLCDReset, IfPm3Lcd, "Hardware reset LCD"},
{"ping", CmdPing, IfPm3Present, "Test if the Proxmark3 is responsive"}, {"ping", CmdPing, IfPm3Present, "Test if the Proxmark3 is responsive"},
@@ -459,10 +724,14 @@ index 860dfa9..e1551b5 100644
} PACKED smart_card_raw_t; } PACKED smart_card_raw_t;
+// For CMD_LED_CONTROL +// For CMD_LED_CONTROL
+// Actions: 0=off, 1=on, 2=toggle, 3=pwm, 4=pulse, 5=fade, 6=blink
+typedef struct { +typedef struct {
+ uint8_t led; // LED bitmask: LED_A=1, LED_B=2, LED_C=4, LED_D=8 + uint8_t led; // LED bitmask: LED_A=1, LED_B=2, LED_C=4, LED_D=8
+ uint8_t action; // 0=off, 1=on, 2=toggle, 3=pwm + uint8_t action; // 0=off, 1=on, 2=toggle, 3=pwm, 4=pulse, 5=fade, 6=blink
+ uint8_t brightness; // 0-100 (for action=3 PWM mode only) + uint8_t brightness; // 0-100 (for PWM-based actions)
+ uint8_t reserved; // Reserved for alignment
+ uint16_t speed_ms; // Effect cycle time in ms (default 500)
+ uint16_t count; // Number of effect cycles (0=infinite)
+} PACKED payload_led_control_t; +} PACKED payload_led_control_t;
+ +

View File

@@ -9,31 +9,39 @@ YELLOW='\033[1;33m'
RED='\033[0;31m' RED='\033[0;31m'
NC='\033[0m' NC='\033[0m'
if ! docker ps --filter name=pigen_work --format "{{.Status}}" &>/dev/null; then # Find running pigen container (handles pigen_work, pigen_work_cont, etc.)
echo -e "${RED}Docker not available${NC}" CONTAINER=$(docker ps --filter name=pigen --format "{{.Names}}" | head -1)
exit 1
fi
STATUS=$(docker ps --filter name=pigen_work --format "{{.Status}}") if [ -z "$CONTAINER" ]; then
# Check for recently exited container
if [ -z "$STATUS" ]; then CONTAINER=$(docker ps -a --filter name=pigen --format "{{.Names}}" | head -1)
if [ -n "$CONTAINER" ]; then
STATUS=$(docker ps -a --filter name="$CONTAINER" --format "{{.Status}}")
echo -e "${YELLOW}Build finished:${NC} $STATUS"
echo ""
echo -e "${BLUE}Final steps:${NC}"
docker logs --tail 20 "$CONTAINER" 2>&1 | grep -E "^\[.*\] (Begin|End)|Build failed|complete" | tail -10
exit 0
fi
echo -e "${YELLOW}No build running${NC}" echo -e "${YELLOW}No build running${NC}"
echo "Start a build with: ./build-image.sh full" echo "Start a build with: ./build-image.sh full"
exit 0 exit 0
fi fi
echo -e "${GREEN}Build Status:${NC} $STATUS" STATUS=$(docker ps --filter name="$CONTAINER" --format "{{.Status}}")
echo -e "${GREEN}Build Status:${NC} $STATUS (container: $CONTAINER)"
echo "" echo ""
echo -e "${BLUE}Current Stage:${NC}" echo -e "${BLUE}Current Stage:${NC}"
docker logs pigen_work 2>&1 | grep -E "^\[.*\] Begin /pi-gen/(stage[^/]+)" | tail -1 docker logs "$CONTAINER" 2>&1 | grep -E "^\[.*\] Begin /pi-gen/(stage[^/]+)" | tail -1
echo "" echo ""
echo -e "${BLUE}Recent Progress (last 10 steps):${NC}" echo -e "${BLUE}Recent Progress (last 10 steps):${NC}"
docker logs pigen_work 2>&1 | grep -E "^\[.*\] (Begin|End)" | tail -10 docker logs "$CONTAINER" 2>&1 | grep -E "^\[.*\] (Begin|End)" | tail -10
echo "" echo ""
echo -e "${BLUE}Latest Activity:${NC}" echo -e "${BLUE}Latest Activity:${NC}"
docker logs --tail 3 pigen_work 2>&1 docker logs --tail 5 "$CONTAINER" 2>&1
echo "" echo ""
echo -e "Watch live: ${YELLOW}docker logs -f pigen_work${NC}" echo -e "Watch live: ${YELLOW}docker logs -f $CONTAINER${NC}"