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:
@@ -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 |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
55
app/backend/api/token_store.py
Normal file
55
app/backend/api/token_store.py
Normal 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]
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
94
app/frontend/app/components/LoginPrompt.tsx
Normal file
94
app/frontend/app/components/LoginPrompt.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Remove this file to enable PiSugar installation
|
|
||||||
# PiSugar support is optional and disabled by default
|
|
||||||
21
pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh
Normal file → Executable file
21
pi-gen/stageDangerousPi/05-https-support/00-run-chroot.sh
Normal file → Executable 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..."
|
||||||
|
|||||||
86
pi-gen/stagePM3/01-proxmark3/hf-booster-detection.patch
Normal file
86
pi-gen/stagePM3/01-proxmark3/hf-booster-detection.patch
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
+
|
+
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user