Initial commit - Phase 3/4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
michael
2026-01-06 13:45:29 -08:00
parent 1da6730735
commit 4f35df1781
323 changed files with 98287 additions and 1195 deletions

View File

@@ -1,14 +0,0 @@
This version of Pi-gen is configured to set up a wifio access point, and SSH.
The default account is username: `dt` password: `proxmark3`
Connect to the `PM3` wifi hot spot with the passphrase `DangerousThings`
Once logged in you will need to compile the PM3 software at the moment.
```
cd proxmark3 && make clean && make -j
sudo make install
```
To remake the image you will need to copy `pm3.config` to `config` and follow the instructions in the `README.md` file

View File

@@ -1,15 +1,16 @@
IMG_NAME="Proxmark3"
USE_QCOW2=0
IMG_NAME="dangerous-pi"
USE_QCOW2=1
RELEASE="trixie"
DEPLOY_ZIP=1
USE_QEMU=0
LOCALE_DEFAULT="en_US.UTF-8"
TARGET_HOSTNAME="Proxmark3"
TARGET_HOSTNAME="dangerous-pi"
KEYBOARD_KEYMAP="us"
TIMEZONE_DEFAULT="Europe/Sofia"
KEYBOARD_LAYOUT="English (US)"
TIMEZONE_DEFAULT="America/Los_Angeles"
FIRST_USER_NAME="dt"
FIRST_USER_PASS="proxmark3"
ENABLE_SSH=1
WPA_COUNTRY="US"
DISABLE_FIRST_BOOT_USER_RENAME=1
STAGE_LIST="stage0 stage1 stage2 stageDTPM3"
STAGE_LIST="stage0 stage1 stage2 stagePM3 stageDangerousPi"

View File

@@ -1,24 +0,0 @@
git
ca-certificates
build-essential
pkg-config
libreadline-dev
gcc-arm-none-eabi
libnewlib-dev
liblz4-dev
libbz2-dev
libbluetooth-dev
libpython3-dev
libssl-dev
hostapd
dnsmasq
dhcpcd
lighttpd
iptables-persistent
vnstat
qrencode
php8.4-cgi
openvpn
wireguard
jq
isoquery

View File

@@ -1,8 +0,0 @@
#!/bin/bash -e
su - dt
git clone https://github.com/RfidResearchGroup/proxmark3
cd proxmark3
echo PLATFORM=PM3GENERIC > Makefile.platform
make clean && make
exit

View File

@@ -1,79 +0,0 @@
#!/bin/bash -e
#rfkill unblock wan
lighttpd-enable-mod fastcgi-php
service lighttpd force-reload || true
systemctl restart lighttpd.service
rm -rf /var/www/html
git clone https://github.com/RaspAP/raspap-webgui /var/www/html
WEBROOT="/var/www/html"
CONFSRC="$WEBROOT/config/50-raspap-router.conf"
LTROOT=$(grep "server.document-root" /etc/lighttpd/lighttpd.conf | awk -F '=' '{print $2}' | tr -d " \"")
HTROOT=${WEBROOT/$LTROOT}
HTROOT=$(echo "$HTROOT" | sed -e 's/\/$//')
awk "{gsub(\"/REPLACE_ME\",\"$HTROOT\")}1" $CONFSRC > /tmp/50-raspap-router.conf
cp /tmp/50-raspap-router.conf /etc/lighttpd/conf-available/
ln -s /etc/lighttpd/conf-available/50-raspap-router.conf /etc/lighttpd/conf-enabled/50-raspap-router.conf
systemctl restart lighttpd.service
cd /var/www/html
cp installers/raspap.sudoers /etc/sudoers.d/090_raspap
mkdir -p /etc/raspap/backups
mkdir /etc/raspap/networking
mkdir /etc/raspap/hostapd
mkdir /etc/raspap/lighttpd
mkdir /etc/raspap/system
chown -R www-data:www-data /var/www/html
chown -R www-data:www-data /etc/raspap
mv installers/*log.sh /etc/raspap/hostapd
mv installers/service*.sh /etc/raspap/hostapd
chown -c root:www-data /etc/raspap/hostapd/*.sh
chmod 750 /etc/raspap/hostapd/*.sh
cp installers/configport.sh /etc/raspap/lighttpd
chown -c root:www-data /etc/raspap/lighttpd/*.sh
mv installers/raspapd.service /lib/systemd/system
cp installers/dhcpcd.service /lib/systemd/system
systemctl daemon-reload || true
systemctl enable raspapd.service
systemctl enable dhcpcd.service
cp /etc/hostapd/hostapd.conf ~/hostapd.conf.old || true
cp config/default_hostapd /etc/default/hostapd || true
cp config/hostapd.conf /etc/hostapd/hostapd.conf
cp config/090_raspap.conf /etc/dnsmasq.d/090_raspap.conf
cp config/090_wlan0.conf /etc/dnsmasq.d/090_wlan0.conf
cp config/dhcpcd.conf /etc/dhcpcd.conf
cp config/config.php /var/www/html/includes/
cp config/defaults.json /etc/raspap/networking/
systemctl stop sytstemd-networkd
systemctl disable systemd-networkd
cp config/raspap-bridge-br0.netdev /etc/systemd/network/raspap-bridge-br0.netdev
cp config/raspap-br0-member-eth0.network /etc/systemd/network/raspap-br0-member-eth0.network
sed -i -E 's/^session\.cookie_httponly\s*=\s*(0|([O|o]ff)|([F|f]alse)|([N|n]o))\s*$/session.cookie_httponly = 1/' /etc/php/8.4/cgi/php.ini
sed -i -E 's/^;?opcache\.enable\s*=\s*(0|([O|o]ff)|([F|f]alse)|([N|n]o))\s*$/opcache.enable = 1/' /etc/php/8.4/cgi/php.ini
phpenmod opcache
echo "net.ipv4.ip_forward=1" | tee /etc/sysctl.d/90_raspap.conf > /dev/null
sysctl -p /etc/sysctl.d/90_raspap.conf
/etc/init.d/procps restart
iptables -t nat -A POSTROUTING -j MASQUERADE
iptables -t nat -A POSTROUTING -s 192.168.50.0/24 ! -d 192.168.50.0/24 -j MASQUERADE
iptables-save > /etc/iptables/rules.v4
systemctl unmask hostapd.service
systemctl enable hostapd.service

View File

@@ -1,56 +0,0 @@
#!/bin/bash -e
# Install Dangerous Pi backend and dependencies
echo "Installing Dangerous Pi..."
# Install Python packages
pip3 install --break-system-packages \
fastapi==0.115.0 \
uvicorn[standard]==0.32.0 \
python-multipart==0.0.12 \
aiosqlite==0.20.0 \
aiohttp==3.10.5 \
httpx==0.27.2 \
sse-starlette==2.1.3 \
pydantic==2.9.0 \
pydantic-settings==2.5.0 \
psutil==6.1.0 \
smbus2==0.4.3
# Create installation directory
mkdir -p /opt/dangerous-pi
cd /opt/dangerous-pi
# Copy application files (from files directory in this stage)
cp -r "${ROOTFS_DIR}/tmp/dangerous-pi-files/"* /opt/dangerous-pi/
# Create data and logs directories
mkdir -p /opt/dangerous-pi/data
mkdir -p /opt/dangerous-pi/logs
# Set ownership and permissions
chown -R pi:pi /opt/dangerous-pi
chmod +x /opt/dangerous-pi/scripts/*.sh
chmod +x /opt/dangerous-pi/systemd/*.sh
# Create environment file from template
cp /opt/dangerous-pi/systemd/dangerous-pi.env.example /opt/dangerous-pi/.env
chown pi:pi /opt/dangerous-pi/.env
chmod 600 /opt/dangerous-pi/.env
# Install systemd service
cp /opt/dangerous-pi/systemd/dangerous-pi.service /etc/systemd/system/
chmod 644 /etc/systemd/system/dangerous-pi.service
# Add pi user to required hardware groups
usermod -a -G i2c,bluetooth,gpio,dialout pi
# Enable I2C interface (for UPS HAT)
if ! grep -q "^dtparam=i2c_arm=on" /boot/config.txt; then
echo "dtparam=i2c_arm=on" >> /boot/config.txt
fi
# Enable service to start on boot
systemctl enable dangerous-pi.service
echo "Dangerous Pi installation complete!"

View File

@@ -1,32 +0,0 @@
#!/bin/bash -e
# Prepare Dangerous Pi files for installation
# This script runs OUTSIDE the chroot environment
# It prepares files that will be copied into the image
echo "Preparing Dangerous Pi files..."
# Create temporary directory for files
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files"
# Copy application files (relative to pi-gen directory)
DANGEROUS_PI_SRC="$(dirname "$(dirname "$(dirname "$(dirname "$0")")")")"
# Copy application structure
cp -r "${DANGEROUS_PI_SRC}/app" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp -r "${DANGEROUS_PI_SRC}/systemd" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp -r "${DANGEROUS_PI_SRC}/scripts" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp "${DANGEROUS_PI_SRC}/requirements.txt" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
# Copy VERSION file if it exists
if [ -f "${DANGEROUS_PI_SRC}/VERSION" ]; then
cp "${DANGEROUS_PI_SRC}/VERSION" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
else
echo "0.1.0" > "${ROOTFS_DIR}/tmp/dangerous-pi-files/VERSION"
fi
# Create empty directories
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/data"
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/logs"
echo "Files prepared successfully"

View File

@@ -1,4 +0,0 @@
IMG_SUFFIX="-pm3"
if [ "${USE_QEMU}" = "1" ]; then
export IMG_SUFFIX="${IMG_SUFFIX}-qemu"
fi

View File

@@ -1,2 +0,0 @@
NOOBS_NAME="Raspberry Pi OS Lite PM3 (64-bit)"
NOOBS_DESCRIPTION="A port of Debian with Proxmrk3 but no desktop environment"

View File

@@ -0,0 +1,95 @@
#!/bin/bash -e
# Enable console access for Pi Zero
# Supports both:
# 1. USB gadget serial (g_serial) - connect via USB data port
# 2. Hardware UART (GPIO pins) - connect via CP210x/FTDI/PL2303 cable
# Date: 2025-11-27
on_chroot << EOF
echo "Configuring console access..."
# Enable USB serial console in systemd (for USB gadget mode)
systemctl enable serial-getty@ttyGS0.service
# Enable hardware UART console (for GPIO serial cables like CP210x/FTDI)
# On Pi Zero 2 W, serial0 maps to ttyS0 (mini UART) when Bluetooth is enabled
# or ttyAMA0 (PL011) when Bluetooth is disabled
systemctl enable serial-getty@serial0.service
# Add g_serial to /etc/modules for automatic loading (more reliable than cmdline.txt)
if ! grep -q "^g_serial" /etc/modules; then
echo "g_serial" >> /etc/modules
echo "✓ Added g_serial to /etc/modules"
else
echo "✓ g_serial already in /etc/modules"
fi
echo "✓ USB console service enabled"
EOF
# Modify boot configuration (runs outside chroot, modifies boot partition)
echo "Enabling USB gadget mode in boot config..."
# Detect boot partition location (newer images use /boot/firmware)
if [ -f "${ROOTFS_DIR}/boot/firmware/config.txt" ]; then
BOOT_DIR="${ROOTFS_DIR}/boot/firmware"
elif [ -f "${ROOTFS_DIR}/boot/config.txt" ]; then
BOOT_DIR="${ROOTFS_DIR}/boot"
else
echo "ERROR: Could not find boot partition"
exit 1
fi
echo "Using boot directory: $BOOT_DIR"
# Add dwc2 overlay to config.txt (peripheral mode for USB gadget)
CONFIG_FILE="$BOOT_DIR/config.txt"
if ! grep -q "dtoverlay=dwc2" "$CONFIG_FILE"; then
echo "" >> "$CONFIG_FILE"
echo "# Enable USB gadget mode (OTG) for USB console access" >> "$CONFIG_FILE"
echo "dtoverlay=dwc2,dr_mode=peripheral" >> "$CONFIG_FILE"
echo "✓ Added dwc2 overlay to config.txt"
else
echo "✓ dwc2 overlay already in config.txt"
fi
# Enable hardware UART for GPIO serial console (CP210x/FTDI/PL2303 cables)
if ! grep -q "^enable_uart=1" "$CONFIG_FILE"; then
echo "" >> "$CONFIG_FILE"
echo "# Enable hardware UART for GPIO serial console" >> "$CONFIG_FILE"
echo "enable_uart=1" >> "$CONFIG_FILE"
echo "✓ Added enable_uart=1 to config.txt"
else
echo "✓ enable_uart already enabled in config.txt"
fi
# Add g_serial module to cmdline.txt
CMDLINE_FILE="$BOOT_DIR/cmdline.txt"
if [ -f "$CMDLINE_FILE" ]; then
# Check if modules-load is already present
if ! grep -q "modules-load=dwc2,g_serial" "$CMDLINE_FILE"; then
# Add after rootwait
sed -i 's/rootwait/rootwait modules-load=dwc2,g_serial/' "$CMDLINE_FILE"
echo "✓ Added g_serial module to cmdline.txt"
else
echo "✓ g_serial module already in cmdline.txt"
fi
# Set default CPU cores for Pi Zero 2 W (2 of 4 cores for thermal management)
# User can change this via Settings > System > CPU Cores
if ! grep -q "maxcpus=" "$CMDLINE_FILE"; then
sed -i 's/$/ maxcpus=2/' "$CMDLINE_FILE"
echo "✓ Set maxcpus=2 for Pi Zero 2 W thermal management"
else
echo "✓ maxcpus already configured in cmdline.txt"
fi
else
echo "ERROR: cmdline.txt not found at $CMDLINE_FILE"
exit 1
fi
echo "=== USB Console Configuration Complete ==="
echo "Connect USB cable to the Pi Zero's DATA port (left port when facing USB ports)"
echo "Access via: screen /dev/ttyACM0 115200 (Linux/Mac)"
echo "Login: dt / proxmark3"

View File

@@ -0,0 +1,419 @@
#!/bin/bash -e
# Simple WiFi Access Point + Captive Portal for Dangerous Pi
# Compatible with Debian Trixie (uses NetworkManager, not dhcpcd)
# No PHP bloat, just hostapd + dnsmasq + nftables
# Date: 2025-12-29
echo "=== Setting up WiFi Access Point ==="
# Install required packages
echo "Installing hostapd, dnsmasq, nginx..."
apt-get install -y \
hostapd \
dnsmasq \
nftables \
nginx
# Note: We no longer use static config file for wlan0 management.
# The wifi_manager now uses 'nmcli device set wlan0 managed yes/no' dynamically.
# This allows switching between AP and client modes without file permission issues.
echo "NetworkManager will manage wlan0 dynamically..."
# Stop services while we configure
systemctl stop hostapd || true
systemctl stop dnsmasq || true
# Configure hostapd (WiFi Access Point)
echo "Configuring hostapd..."
cat > /etc/hostapd/hostapd.conf << 'EOF'
# Dangerous Pi WiFi Access Point Configuration
# Interface and driver
interface=wlan0
driver=nl80211
# Network name (SSID)
ssid=Dangerous-Pi
# WiFi channel (1-11 for 2.4GHz)
channel=6
# Country code (US = United States, change as needed)
country_code=US
# WiFi mode: a = IEEE 802.11a (5 GHz), b = IEEE 802.11b (2.4 GHz), g = IEEE 802.11g (2.4 GHz)
hw_mode=g
# Enable 802.11n
ieee80211n=1
# QoS support
wmm_enabled=1
# Security: WPA2-PSK
# Comment out wpa lines for open network
wpa=2
wpa_passphrase=dangerous123
wpa_key_mgmt=WPA-PSK
wpa_pairwise=TKIP
rsn_pairwise=CCMP
# Logging
logger_syslog=-1
logger_syslog_level=2
logger_stdout=-1
logger_stdout_level=2
EOF
# Set hostapd to use our config
cat > /etc/default/hostapd << 'EOF'
# Defaults for hostapd initscript
DAEMON_CONF="/etc/hostapd/hostapd.conf"
EOF
# Configure dnsmasq (DHCP + DNS for captive portal)
echo "Configuring dnsmasq..."
# Backup original dnsmasq config
mv /etc/dnsmasq.conf /etc/dnsmasq.conf.orig || true
cat > /etc/dnsmasq.conf << 'EOF'
# Dangerous Pi Captive Portal Configuration
# Interface to listen on
interface=wlan0
# Bind to interfaces to prevent startup race conditions
bind-interfaces
# Don't use /etc/hosts
no-hosts
# Don't poll /etc/resolv.conf
no-resolv
# Upstream DNS servers (when acting as router)
server=8.8.8.8
server=8.8.4.4
# DHCP range
# Assign IPs from 192.168.4.2 to 192.168.4.20
# Lease time: 12 hours
dhcp-range=192.168.4.2,192.168.4.20,255.255.255.0,12h
# Gateway (this Pi)
dhcp-option=3,192.168.4.1
# DNS server (this Pi)
dhcp-option=6,192.168.4.1
# CAPTIVE PORTAL: Redirect ALL DNS queries to this Pi
# This makes browsers think there's a captive portal
address=/#/192.168.4.1
# Don't read /etc/resolv.conf
no-resolv
# Log DHCP
log-dhcp
# Log DNS
log-queries
EOF
# Configure static IP for wlan0 using systemd-networkd
# Note: Debian Trixie uses NetworkManager, but we've told it to ignore wlan0
# We use a simple systemd service to set the IP before hostapd starts
echo "Configuring network interface..."
# Enable IP forwarding (for NAT if connected to internet via eth0)
echo "Enabling IP forwarding..."
if ! grep -q "net.ipv4.ip_forward=1" /etc/sysctl.conf; then
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
fi
# Configure nftables for NAT and captive portal (modern replacement for iptables)
echo "Configuring nftables..."
cat > /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
# Dangerous Pi NAT + Captive Portal configuration (nftables)
# Flush existing rules
flush ruleset
table inet nat {
# NAT chain for outgoing packets (masquerade)
chain postrouting {
type nat hook postrouting priority 100; policy accept;
# Masquerade traffic from WiFi clients going out eth0
oifname "eth0" ip saddr 192.168.4.0/24 masquerade
}
# Prerouting chain (no redirect - nginx handles port 80)
chain prerouting {
type nat hook prerouting priority -100; policy accept;
# Note: nginx on port 80 proxies to frontend (3000) and backend (8000)
}
}
table inet filter {
# Filter rules for forwarding
chain forward {
type filter hook forward priority 0; policy drop;
# Allow established/related connections
ct state related,established accept
# Allow WiFi clients to access internet via eth0
iifname "wlan0" oifname "eth0" accept
# Allow return traffic
iifname "eth0" oifname "wlan0" ct state related,established accept
}
# Input rules
chain input {
type filter hook input priority 0; policy accept;
# Allow loopback
iifname "lo" accept
# Allow established connections
ct state related,established accept
# Allow DHCP, DNS from WiFi clients
iifname "wlan0" udp dport { 53, 67 } accept
# Allow HTTP (nginx), HTTPS, backend (8000), and frontend (3000)
iifname "wlan0" tcp dport { 53, 80, 443, 3000, 8000, 8080 } accept
# Allow SSH from anywhere
tcp dport 22 accept
}
}
EOF
# Enable and start nftables
systemctl enable nftables
systemctl start nftables || true
# Configure nginx as reverse proxy for frontend and backend
echo "Configuring nginx reverse proxy..."
rm -f /etc/nginx/sites-enabled/default
cat > /etc/nginx/sites-available/dangerous-pi.conf << 'EOF'
# Dangerous Pi - Nginx reverse proxy configuration
# Serves frontend on / and proxies /api/*, /ws/* to backend
upstream frontend {
server 127.0.0.1:3000;
}
upstream backend {
server 127.0.0.1:8000;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name dangerous-pi.local _;
# ===========================================
# CAPTIVE PORTAL DETECTION
# ===========================================
# These endpoints respond to OS connectivity checks.
# When a device connects to the AP, the OS checks these URLs.
# By responding correctly, we trigger the captive portal popup.
# Android / Chrome OS connectivity check
location = /generate_204 {
return 204;
}
# Additional Android check paths
location = /gen_204 {
return 204;
}
# Google connectivity check
location = /connectivitycheck/gstatic/generate_204 {
return 204;
}
# iOS / macOS captive portal detection
location = /hotspot-detect.html {
return 200 '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>';
add_header Content-Type text/html;
}
# iOS alternate path
location = /library/test/success.html {
return 200 '<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>';
add_header Content-Type text/html;
}
# Windows 10/11 connectivity check
location = /connecttest.txt {
return 200 'Microsoft Connect Test';
add_header Content-Type text/plain;
}
# Windows NCSI check
location = /ncsi.txt {
return 200 'Microsoft NCSI';
add_header Content-Type text/plain;
}
# Firefox / Mozilla connectivity check
location = /success.txt {
return 200 'success';
add_header Content-Type text/plain;
}
# Kindle / Amazon devices
location = /kindle-wifi/wifistub.html {
return 200 '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN"><html><head><title>Kindle</title></head><body>81ce4465-7167-4dcb-835b-dcc9e44c112a</body></html>';
add_header Content-Type text/html;
}
# ===========================================
# END CAPTIVE PORTAL DETECTION
# ===========================================
# Proxy API requests to FastAPI backend
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# Proxy WebSocket connections to backend
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Everything else goes to Remix frontend
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml;
}
EOF
ln -sf /etc/nginx/sites-available/dangerous-pi.conf /etc/nginx/sites-enabled/
systemctl enable nginx
# Unmask and enable hostapd
echo "Enabling hostapd service..."
systemctl unmask hostapd
systemctl enable hostapd
# Enable dnsmasq
systemctl enable dnsmasq
# NOTE: No separate web server needed - nftables redirects port 80 to the backend on 8000
# Create Avahi service for dangerous-pi.local
echo "Configuring mDNS..."
# Ensure avahi services directory exists
mkdir -p /etc/avahi/services
cat > /etc/avahi/services/dangerous-pi.service << 'EOF'
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">Dangerous Pi on %h</name>
<service>
<type>_http._tcp</type>
<port>80</port>
</service>
</service-group>
EOF
# Create systemd service to set up AP network interface
# This sets static IP on wlan0 before hostapd/dnsmasq start
cat > /etc/systemd/system/dangerous-pi-ap.service << 'EOF'
[Unit]
Description=Dangerous Pi WiFi Access Point Setup
After=network.target NetworkManager.service
Before=hostapd.service dnsmasq.service nftables.service
[Service]
Type=oneshot
RemainAfterExit=yes
# Unblock WiFi
ExecStart=/usr/sbin/rfkill unblock wlan
# Wait for interface to appear
ExecStart=/bin/sleep 1
# Tell NetworkManager to not manage wlan0 (so hostapd can use it)
ExecStart=/usr/bin/nmcli device set wlan0 managed no
# Set static IP for AP mode
ExecStart=/sbin/ip addr add 192.168.4.1/24 dev wlan0
ExecStart=/sbin/ip link set wlan0 up
# Load nftables rules for captive portal
ExecStart=/usr/sbin/nft -f /etc/nftables.conf
[Install]
WantedBy=multi-user.target
EOF
systemctl enable dangerous-pi-ap.service
# Ensure hostapd and dnsmasq start AFTER network is configured
mkdir -p /etc/systemd/system/hostapd.service.d
cat > /etc/systemd/system/hostapd.service.d/override.conf << 'EOF'
[Unit]
After=dangerous-pi-ap.service
Requires=dangerous-pi-ap.service
EOF
mkdir -p /etc/systemd/system/dnsmasq.service.d
cat > /etc/systemd/system/dnsmasq.service.d/override.conf << 'EOF'
[Unit]
After=dangerous-pi-ap.service hostapd.service
Requires=dangerous-pi-ap.service
EOF
# Print configuration summary
echo "=== WiFi Access Point Configuration Complete ==="
echo "SSID: Dangerous-Pi"
echo "Password: dangerous123"
echo "AP IP: 192.168.4.1"
echo "DHCP Range: 192.168.4.2 - 192.168.4.20"
echo "Access via: http://dangerous-pi.local or http://192.168.4.1"
echo ""
echo "Captive portal detection enabled: OS connectivity checks will trigger portal popup"
echo ""
echo "✓ Access Point configuration successful"

View File

@@ -16,7 +16,18 @@ esac
cp ttyd.${model} /usr/local/bin/ttyd
chown root:root /usr/local/bin/ttyd
chmod a+x /usr/local/bin/ttyd
USER_UID=$(id -u dt)
# Detect the default user (cloud-init compatible)
# Find first user with UID >= 1000 (excluding nobody)
DEFAULT_USER=$(awk -F: '$3 >= 1000 && $3 < 65534 {print $1; exit}' /etc/passwd)
if [ -z "$DEFAULT_USER" ]; then
DEFAULT_USER="pi" # Fallback to pi if detection fails
fi
DEFAULT_HOME=$(eval echo ~$DEFAULT_USER)
echo "Detected default user: $DEFAULT_USER (home: $DEFAULT_HOME)"
USER_UID=$(id -u $DEFAULT_USER)
cat > /etc/systemd/system/ttyd-bash.service << EOF
[Unit]
@@ -27,9 +38,9 @@ After=network.target
WantedBy=multi-user.target
[Service]
User=dt
WorkingDirectory=/home/dt
ExecStart=/usr/local/bin/ttyd -p 8000 -W -u $USER_UID -m 1 -c dt:proxmark3 /usr/bin/bash
User=$DEFAULT_USER
WorkingDirectory=$DEFAULT_HOME
ExecStart=/usr/local/bin/ttyd -p 8000 -W -u $USER_UID -m 1 -c $DEFAULT_USER:proxmark3 /usr/bin/bash
Restart=always
Type=simple
RestartSec=1
@@ -46,8 +57,8 @@ ConditionFileIsExecutable=/usr/local/bin/pm3
WantedBy=multi-user.target
[Service]
User=dt
WorkingDirectory=/home/dt
User=$DEFAULT_USER
WorkingDirectory=$DEFAULT_HOME
ExecStart=/usr/local/bin/ttyd -p 8080 -W -u $USER_UID -m 1 /usr/local/bin/pm3
Restart=always
Type=simple
@@ -68,5 +79,8 @@ then
ln -s /etc/systemd/system/ttyd-pm3.service /etc/systemd/system/multi-user.target.wants
fi
cd /home/dt/proxmark3
make install
# PM3 is now installed to user's home/.pm3/proxmark3/ instead of make install
# Create symlink for backwards compatibility if needed
if [ ! -f /usr/local/bin/pm3 ] && [ -f $DEFAULT_HOME/.pm3/proxmark3/client/proxmark3 ]; then
ln -s $DEFAULT_HOME/.pm3/proxmark3/client/proxmark3 /usr/local/bin/pm3
fi

View File

@@ -0,0 +1,153 @@
#!/bin/bash -e
# Install Dangerous Pi backend and dependencies
echo "Installing Dangerous Pi..."
# Detect the default user (cloud-init compatible)
# Find first user with UID >= 1000 (excluding nobody)
DEFAULT_USER=$(awk -F: '$3 >= 1000 && $3 < 65534 {print $1; exit}' /etc/passwd)
if [ -z "$DEFAULT_USER" ]; then
DEFAULT_USER="pi" # Fallback to pi if detection fails
fi
DEFAULT_HOME=$(eval echo ~$DEFAULT_USER)
echo "Detected default user: $DEFAULT_USER (home: $DEFAULT_HOME)"
# Install pip3 if not already installed
if ! command -v pip3 &> /dev/null; then
echo "Installing pip3..."
apt-get update
apt-get install -y python3-pip
fi
# Install Node.js for frontend (Remix SSR) and utilities
echo "Installing Node.js and utilities..."
apt-get install -y nodejs npm lrzsz
# Install Python packages from requirements.txt
pip3 install --break-system-packages -r /tmp/dangerous-pi-files/requirements.txt
# Create installation directory
mkdir -p /opt/dangerous-pi
cd /opt/dangerous-pi
# Copy application files (from temp directory prepared by 00-run.sh)
cp -r /tmp/dangerous-pi-files/* /opt/dangerous-pi/
# Create data and logs directories
mkdir -p /opt/dangerous-pi/data
mkdir -p /opt/dangerous-pi/logs
# Set ownership and permissions
chown -R $DEFAULT_USER:$DEFAULT_USER /opt/dangerous-pi
chmod +x /opt/dangerous-pi/scripts/*.sh
chmod +x /opt/dangerous-pi/systemd/*.sh
# Create environment file from template
cp /opt/dangerous-pi/systemd/dangerous-pi.env.example /opt/dangerous-pi/.env
chown $DEFAULT_USER:$DEFAULT_USER /opt/dangerous-pi/.env
chmod 600 /opt/dangerous-pi/.env
# Install backend systemd service (with user substitution for cloud-init compatibility)
sed "s/__DANGEROUS_PI_USER__/$DEFAULT_USER/g" /opt/dangerous-pi/systemd/dangerous-pi.service > /etc/systemd/system/dangerous-pi.service
chmod 644 /etc/systemd/system/dangerous-pi.service
# Install polkit rules for sudo-free network management
if [ -d /tmp/dangerous-pi-files/etc/polkit-1 ]; then
mkdir -p /etc/polkit-1/rules.d
sed "s/__DANGEROUS_PI_USER__/$DEFAULT_USER/g" /tmp/dangerous-pi-files/etc/polkit-1/rules.d/50-dangerous-pi.rules > /etc/polkit-1/rules.d/50-dangerous-pi.rules
chmod 644 /etc/polkit-1/rules.d/50-dangerous-pi.rules
echo "Installed polkit rules for $DEFAULT_USER"
fi
# Add user to netdev group for network management
usermod -a -G netdev $DEFAULT_USER
# Build frontend (Remix SSR)
echo "Building frontend..."
cd /opt/dangerous-pi/app/frontend
npm ci --production=false # Install dev deps for build
npm run build
# Remove dev dependencies and node_modules bloat after build
rm -rf node_modules/.cache
npm prune --production
cd /opt/dangerous-pi
# Install frontend systemd service
cat > /etc/systemd/system/dangerous-pi-frontend.service << EOF
[Unit]
Description=Dangerous Pi Frontend Service
Documentation=https://github.com/grizmin/dangerous-pi
After=network.target dangerous-pi.service
Wants=dangerous-pi.service
[Service]
Type=simple
User=$DEFAULT_USER
Group=$DEFAULT_USER
WorkingDirectory=/opt/dangerous-pi/app/frontend
Environment="NODE_ENV=production"
Environment="PORT=3000"
ExecStart=/usr/bin/node node_modules/@remix-run/serve/dist/cli.js build/server/index.js
# Restart policy
Restart=always
RestartSec=5
StartLimitBurst=5
StartLimitInterval=60
# Resource limits (lighter than backend)
MemoryMax=256M
CPUQuota=50%
# Graceful shutdown
TimeoutStopSec=10
KillMode=mixed
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
EOF
chmod 644 /etc/systemd/system/dangerous-pi-frontend.service
# Add default user to required hardware groups
usermod -a -G i2c,bluetooth,gpio,dialout $DEFAULT_USER
# Enable I2C interface (for UPS HAT)
# Try both possible boot config locations (legacy and modern Pi OS)
BOOT_CONFIG="/boot/firmware/config.txt"
if [ ! -f "$BOOT_CONFIG" ]; then
BOOT_CONFIG="/boot/config.txt"
fi
if [ -f "$BOOT_CONFIG" ]; then
if ! grep -q "^dtparam=i2c_arm=on" "$BOOT_CONFIG"; then
echo "dtparam=i2c_arm=on" >> "$BOOT_CONFIG"
fi
else
echo "WARNING: Boot config not found at /boot/config.txt or /boot/firmware/config.txt"
fi
# Ensure i2c-dev module loads at boot (required for /dev/i2c-* devices)
echo "Configuring i2c-dev module to load at boot..."
mkdir -p /etc/modules-load.d
echo "i2c-dev" > /etc/modules-load.d/i2c-dev.conf
# Enable services to start on boot
systemctl enable dangerous-pi.service
systemctl enable dangerous-pi-frontend.service
# Clean up package manager cache (saves ~50MB)
apt-get clean
apt-get autoclean
apt-get autoremove -y
# Clean pip cache
pip3 cache purge 2>/dev/null || true
# Clean npm cache
npm cache clean --force 2>/dev/null || true
echo "Dangerous Pi installation complete!"
echo "Frontend: http://dangerous-pi.local (via nginx)"
echo "Backend API: http://dangerous-pi.local/api/"

View File

@@ -0,0 +1,29 @@
#!/bin/bash -e
# Prepare Dangerous Pi files for installation
# This script runs OUTSIDE the chroot environment
# It prepares files that will be copied into the image
echo "Preparing Dangerous Pi files..."
# Create temporary directory for files
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files"
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Copy application structure from the files directory
cp -r "${SCRIPT_DIR}/files/app" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp -r "${SCRIPT_DIR}/files/systemd" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp -r "${SCRIPT_DIR}/files/scripts" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp -r "${SCRIPT_DIR}/files/etc" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
cp "${SCRIPT_DIR}/files/requirements.txt" "${ROOTFS_DIR}/tmp/dangerous-pi-files/"
# Create VERSION file
echo "0.1.0-$(date +%Y%m%d)" > "${ROOTFS_DIR}/tmp/dangerous-pi-files/VERSION"
# Create empty directories
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/data"
mkdir -p "${ROOTFS_DIR}/tmp/dangerous-pi-files/logs"
echo "Files prepared successfully"

View File

@@ -3,12 +3,12 @@
echo "Handling port conflicts..."
# Option 1: Disable ttyd-bash (default)
# Uncomment to use this option:
# if systemctl is-enabled ttyd-bash 2>/dev/null; then
# systemctl disable ttyd-bash
# echo "Disabled ttyd-bash to avoid port conflict"
# fi
# Option 1: Disable ttyd-bash (default - recommended)
# This frees up port 8000 for Dangerous Pi
if systemctl is-enabled ttyd-bash 2>/dev/null; then
systemctl disable ttyd-bash
echo "Disabled ttyd-bash to avoid port conflict with Dangerous Pi (port 8000)"
fi
# Option 2: Change Dangerous Pi port to 8001
# Uncomment to use this option:
@@ -24,7 +24,7 @@ echo "Handling port conflicts..."
# echo "Changed ttyd-bash port to 8002"
# fi
# By default, we do nothing and let the user resolve it post-install
# This prevents accidental breakage of existing setups
echo "Port conflict resolution deferred to post-install"
echo "Run /opt/dangerous-pi/scripts/resolve-port-conflict.sh after boot"
# Port conflict handled automatically by disabling ttyd-bash
# Users can manually re-enable it and configure alternative ports if needed
# See /opt/dangerous-pi/scripts/resolve-port-conflict.sh for other options
echo "Port conflict resolution complete"

View File

@@ -0,0 +1 @@
"""Dangerous Pi application."""

View File

@@ -0,0 +1 @@
"""API routers."""

View File

@@ -0,0 +1,63 @@
"""Authentication module for Dangerous Pi API."""
import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .. import config
security = HTTPBasic()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str:
"""Verify HTTP Basic Auth credentials.
Args:
credentials: HTTP Basic credentials from request
Returns:
Username if authentication successful
Raises:
HTTPException: If authentication fails
"""
if not config.AUTH_ENABLED:
return "anonymous"
if not config.AUTH_PASSWORD:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AUTH_ENABLED is true but AUTH_PASSWORD is not set",
)
# Use constant-time comparison to prevent timing attacks
is_correct_username = secrets.compare_digest(
credentials.username.encode("utf-8"),
config.AUTH_USERNAME.encode("utf-8")
)
is_correct_password = secrets.compare_digest(
credentials.password.encode("utf-8"),
config.AUTH_PASSWORD.encode("utf-8")
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
def get_optional_auth(credentials: HTTPBasicCredentials = Depends(security)) -> str | None:
"""Optional authentication - returns username or None.
Use this for endpoints where auth is optional based on config.
"""
if not config.AUTH_ENABLED:
return None
try:
return verify_credentials(credentials)
except HTTPException:
return None

View File

@@ -0,0 +1,26 @@
"""Health check endpoints."""
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class HealthResponse(BaseModel):
status: str
version: str
@router.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check endpoint."""
return HealthResponse(
status="healthy",
version="0.1.0"
)
@router.get("/ready")
async def readiness_check():
"""Readiness check endpoint."""
# TODO: Check if PM3 is connected, database is accessible, etc.
return {"ready": True}

View File

@@ -0,0 +1,241 @@
"""Plugin management API endpoints."""
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..managers.plugin_manager import get_plugin_manager
router = APIRouter()
class PluginMetadataResponse(BaseModel):
"""Plugin metadata response model."""
id: str
name: str
version: str
description: str
author: str
homepage: Optional[str] = None
dependencies: List[str] = []
permissions: List[str] = []
class PluginInfoResponse(BaseModel):
"""Plugin info response model."""
metadata: PluginMetadataResponse
status: str
enabled: bool
error_message: Optional[str] = None
class PluginActionRequest(BaseModel):
"""Request model for plugin actions."""
plugin_id: str
@router.get("/discover")
async def discover_plugins():
"""Discover all available plugins.
Returns:
List of discovered plugin IDs
"""
try:
manager = get_plugin_manager()
discovered = await manager.discover_plugins()
return {
"success": True,
"plugins": discovered,
"count": len(discovered)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/list", response_model=List[PluginInfoResponse])
async def list_plugins():
"""Get list of all plugins.
Returns:
List of plugin information
"""
try:
manager = get_plugin_manager()
plugins = manager.get_all_plugins()
result = []
for plugin_id, plugin_info in plugins.items():
result.append(PluginInfoResponse(
metadata=PluginMetadataResponse(
id=plugin_info.metadata.id,
name=plugin_info.metadata.name,
version=plugin_info.metadata.version,
description=plugin_info.metadata.description,
author=plugin_info.metadata.author,
homepage=plugin_info.metadata.homepage,
dependencies=plugin_info.metadata.dependencies,
permissions=plugin_info.metadata.permissions
),
status=plugin_info.status.value,
enabled=plugin_info.enabled,
error_message=plugin_info.error_message
))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{plugin_id}", response_model=PluginInfoResponse)
async def get_plugin_info(plugin_id: str):
"""Get information about a specific plugin.
Args:
plugin_id: ID of the plugin
Returns:
Plugin information
"""
try:
manager = get_plugin_manager()
plugin_info = manager.get_plugin_info(plugin_id)
if not plugin_info:
raise HTTPException(status_code=404, detail="Plugin not found")
return PluginInfoResponse(
metadata=PluginMetadataResponse(
id=plugin_info.metadata.id,
name=plugin_info.metadata.name,
version=plugin_info.metadata.version,
description=plugin_info.metadata.description,
author=plugin_info.metadata.author,
homepage=plugin_info.metadata.homepage,
dependencies=plugin_info.metadata.dependencies,
permissions=plugin_info.metadata.permissions
),
status=plugin_info.status.value,
enabled=plugin_info.enabled,
error_message=plugin_info.error_message
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/enable")
async def enable_plugin(request: PluginActionRequest):
"""Enable a plugin.
Args:
request: Plugin action request with plugin_id
Returns:
Success message
"""
try:
manager = get_plugin_manager()
success = await manager.enable_plugin(request.plugin_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to enable plugin")
return {
"success": True,
"message": f"Plugin {request.plugin_id} enabled"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/disable")
async def disable_plugin(request: PluginActionRequest):
"""Disable a plugin.
Args:
request: Plugin action request with plugin_id
Returns:
Success message
"""
try:
manager = get_plugin_manager()
success = await manager.disable_plugin(request.plugin_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to disable plugin")
return {
"success": True,
"message": f"Plugin {request.plugin_id} disabled"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/load")
async def load_plugin(request: PluginActionRequest):
"""Load a plugin into memory.
Args:
request: Plugin action request with plugin_id
Returns:
Success message
"""
try:
manager = get_plugin_manager()
success = await manager.load_plugin(request.plugin_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to load plugin")
return {
"success": True,
"message": f"Plugin {request.plugin_id} loaded"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/unload")
async def unload_plugin(request: PluginActionRequest):
"""Unload a plugin from memory.
Args:
request: Plugin action request with plugin_id
Returns:
Success message
"""
try:
manager = get_plugin_manager()
success = await manager.unload_plugin(request.plugin_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to unload plugin")
return {
"success": True,
"message": f"Plugin {request.plugin_id} unloaded"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,398 @@
"""Proxmark3 API endpoints.
Refactored to use PM3Service for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
Multi-device support:
- GET /devices - List all devices
- GET /devices/available - List available devices
- POST /devices/{device_id}/identify - Blink device LEDs
- GET /devices/{device_id}/status - Get device-specific status
- GET /status?device_id=xxx - Get status (all devices or specific)
- POST /command - Execute command (with optional device_id)
"""
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from ..services.container import container
router = APIRouter()
class CommandRequest(BaseModel):
command: str
session_id: Optional[str] = None
device_id: Optional[str] = None # Multi-device support
class CommandResponse(BaseModel):
success: bool
output: str
error: Optional[str] = None
class StatusResponse(BaseModel):
connected: bool
device: str
version: Optional[str] = None
session_active: bool
class FirmwareInfo(BaseModel):
"""Firmware information for a device."""
bootrom_version: Optional[str] = None
os_version: Optional[str] = None
compatible: bool = False
class DeviceInfo(BaseModel):
"""Device information response."""
device_id: str
device_path: str
friendly_name: Optional[str] = None
serial_number: Optional[str] = None
status: str
connected: bool
in_use: bool
firmware_info: FirmwareInfo
usb_vid: Optional[str] = None
usb_pid: Optional[str] = None
last_seen: str
class DeviceListResponse(BaseModel):
"""Response for device list endpoints."""
devices: List[DeviceInfo]
class DeviceStatusResponse(BaseModel):
"""Response for single device status."""
device_id: str
device_path: str
friendly_name: Optional[str] = None
serial_number: Optional[str] = None
connected: bool
in_use: bool
status: str
firmware_info: FirmwareInfo
last_seen: str
class IdentifyRequest(BaseModel):
"""Request to identify a device (blink LEDs)."""
duration_ms: int = Field(default=2000, ge=500, le=10000, description="LED blink duration in milliseconds")
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = {
# Session errors
"session_locked": 423,
"session_not_found": 404,
# Connection errors
"pm3_not_connected": 503,
"connection_error": 503,
"disconnection_error": 500,
# Command execution errors
"command_failed": 500,
"execution_error": 500,
"status_error": 500,
# Multi-device errors
"device_not_found": 404,
"device_manager_not_available": 503,
"list_devices_error": 500,
"get_available_devices_error": 500,
"identify_device_error": 500,
}
return codes.get(error_code, 500)
@router.get("/status")
async def get_status(device_id: Optional[str] = Query(None, description="Optional device ID for multi-device support")):
"""Get Proxmark3 status.
Multi-device support:
- If device_id is None and device_manager exists: returns all devices
- If device_id is provided: returns specific device status
- If device_id is None and no device_manager: returns legacy single device status
Args:
device_id: Optional device ID for multi-device mode
Returns:
StatusResponse (legacy single device) or dict with devices list (multi-device)
"""
result = await container.pm3_service.get_status(device_id=device_id)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Multi-device mode: return all devices
if "devices" in result.data:
return {"devices": result.data["devices"]}
# Single device mode (specific device or legacy)
return StatusResponse(
connected=result.data["connected"],
device=result.data["device_path"],
version=result.data.get("version"),
session_active=result.data["session_active"]
)
@router.post("/connect")
async def connect():
"""Connect to Proxmark3 device.
Uses PM3Service for business logic.
"""
result = await container.pm3_service.connect()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/disconnect")
async def disconnect():
"""Disconnect from Proxmark3 device.
Uses PM3Service for business logic.
"""
result = await container.pm3_service.disconnect()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/command", response_model=CommandResponse)
async def execute_command(request: CommandRequest):
"""Execute a Proxmark3 command.
Uses PM3Service for all business logic including:
- Session validation
- Command execution
- Session activity updates
- Multi-device support (via device_id in request)
Multi-device support:
- If device_id is provided: command executes on specified device
- If device_id is None: uses legacy single-device mode
Args:
request: CommandRequest with command, optional session_id, and optional device_id
"""
result = await container.pm3_service.execute_command(
command=request.command,
session_id=request.session_id,
device_id=request.device_id
)
if result.success:
return CommandResponse(
success=True,
output=result.data["output"],
error=None
)
else:
# For session_locked, return 423 via HTTPException
# For other errors, return in response body
if result.error.code == "session_locked":
raise HTTPException(
status_code=423,
detail=result.error.message
)
# Include details in error message for better diagnostics
error_msg = result.error.message
if result.error.details:
error_msg = f"{error_msg}: {result.error.details}"
return CommandResponse(
success=False,
output="",
error=error_msg
)
@router.get("/commands/history")
async def get_command_history(limit: int = 50):
"""Get recent command history.
TODO: Implement command history in PM3Service or separate HistoryService
"""
# TODO: Implement database query
return {"history": []}
# ============================================================================
# Multi-Device Endpoints
# ============================================================================
@router.get("/devices", response_model=DeviceListResponse)
async def list_devices():
"""List all discovered PM3 devices.
Returns all devices regardless of their status (connected, in use, etc.).
Uses PM3DeviceManager via PM3Service.
Returns:
DeviceListResponse: List of all devices with their status and firmware info
"""
result = await container.pm3_service.list_devices()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Convert device dictionaries to DeviceInfo models
devices = []
for device_dict in result.data["devices"]:
devices.append(DeviceInfo(
device_id=device_dict["device_id"],
device_path=device_dict["device_path"],
friendly_name=device_dict.get("friendly_name"),
serial_number=device_dict.get("serial_number"),
status=device_dict["status"],
connected=device_dict["status"] in ["CONNECTED", "IN_USE"],
in_use=device_dict["status"] == "IN_USE",
firmware_info=FirmwareInfo(
bootrom_version=device_dict["firmware_info"].get("bootrom_version"),
os_version=device_dict["firmware_info"].get("os_version"),
compatible=device_dict["firmware_info"].get("compatible", False)
),
usb_vid=device_dict.get("usb_vid"),
usb_pid=device_dict.get("usb_pid"),
last_seen=device_dict["last_seen"]
))
return DeviceListResponse(devices=devices)
@router.get("/devices/available", response_model=DeviceListResponse)
async def list_available_devices():
"""List available PM3 devices (not currently in use).
Returns only devices that are connected but do not have active sessions.
These devices can be selected for new operations.
Returns:
DeviceListResponse: List of available devices
"""
result = await container.pm3_service.get_available_devices()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Convert device dictionaries to DeviceInfo models
devices = []
for device_dict in result.data["devices"]:
devices.append(DeviceInfo(
device_id=device_dict["device_id"],
device_path=device_dict["device_path"],
friendly_name=device_dict.get("friendly_name"),
serial_number=device_dict.get("serial_number"),
status=device_dict["status"],
connected=device_dict["status"] in ["CONNECTED", "IN_USE"],
in_use=device_dict["status"] == "IN_USE",
firmware_info=FirmwareInfo(
bootrom_version=device_dict["firmware_info"].get("bootrom_version"),
os_version=device_dict["firmware_info"].get("os_version"),
compatible=device_dict["firmware_info"].get("compatible", False)
),
usb_vid=device_dict.get("usb_vid"),
usb_pid=device_dict.get("usb_pid"),
last_seen=device_dict["last_seen"]
))
return DeviceListResponse(devices=devices)
@router.post("/devices/{device_id}/identify")
async def identify_device(device_id: str, request: IdentifyRequest = IdentifyRequest()):
"""Identify a PM3 device by blinking its LEDs.
Useful for physically identifying which device corresponds to a device_id
when multiple devices are connected.
Args:
device_id: Device ID to identify
request: Request with optional duration_ms (default: 2000ms)
Returns:
Success message
"""
result = await container.pm3_service.identify_device(
device_id=device_id,
duration_ms=request.duration_ms
)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.get("/devices/{device_id}/status", response_model=DeviceStatusResponse)
async def get_device_status(device_id: str):
"""Get status for a specific PM3 device.
Args:
device_id: Device ID to query
Returns:
DeviceStatusResponse: Device status and firmware info
"""
result = await container.pm3_service.get_status(device_id=device_id)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
data = result.data
return DeviceStatusResponse(
device_id=data["device_id"],
device_path=data["device_path"],
friendly_name=data.get("friendly_name"),
serial_number=data.get("serial_number"),
connected=data["connected"],
in_use=data["in_use"],
status=data["status"],
firmware_info=FirmwareInfo(
bootrom_version=data["firmware_info"]["bootrom_version"],
os_version=data["firmware_info"]["os_version"],
compatible=data["firmware_info"]["compatible"]
),
last_seen=data["last_seen"]
)

View File

@@ -0,0 +1,571 @@
"""System API endpoints.
Refactored to use services for business logic.
Session management uses PM3Service, system operations use SystemService.
"""
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, Dict
from ..services.container import container
from ..managers.ups_manager import get_ups_manager
from ..managers.ble_manager import get_ble_manager
router = APIRouter()
class CreateSessionRequest(BaseModel):
force_takeover: bool = False
device_id: Optional[str] = None # Device to create session for
class CreateSessionResponse(BaseModel):
success: bool
session_id: Optional[str] = None
device_id: Optional[str] = None
error: Optional[str] = None
class SessionInfo(BaseModel):
session_id: str
device_id: Optional[str]
client_ip: str
created_at: float
last_activity: float
time_remaining: float
class CPUCoreInfo(BaseModel):
"""Per-core CPU information."""
core_id: int
percent: float
online: bool = True # Whether this core is currently enabled
class CPUInfo(BaseModel):
"""CPU information including per-core data."""
count: int
percent: float
temperature: Optional[float] = None
per_core: list[CPUCoreInfo] = []
load_average: Optional[list[float]] = None
class SystemInfo(BaseModel):
"""System information response."""
hostname: str
uptime: float
cpu_temp: Optional[float]
cpu: Optional[CPUInfo] = None
memory_used: float
memory_total: float
disk_used: float
disk_total: float
@router.post("/session/create", response_model=CreateSessionResponse)
async def create_session(request: Request, body: CreateSessionRequest):
"""Create a new session for PM3 access.
Uses PM3Service for session management.
Optionally specify device_id for multi-device support.
"""
# Get client IP from request
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
result = await container.pm3_service.create_session(
client_ip=client_ip,
user_agent=user_agent,
force_takeover=body.force_takeover,
device_id=body.device_id
)
if result.success:
return CreateSessionResponse(
success=True,
session_id=result.data["session_id"],
device_id=result.data.get("device_id"),
error=None
)
else:
return CreateSessionResponse(
success=False,
session_id=None,
device_id=None,
error=result.error.message
)
@router.post("/session/{session_id}/release")
async def release_session(session_id: str, device_id: Optional[str] = None):
"""Release an active session.
Uses PM3Service for session management.
Args:
session_id: Session ID to release
device_id: Optional device ID for faster lookup
"""
result = await container.pm3_service.release_session(session_id, device_id)
if not result.success:
raise HTTPException(
status_code=404,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.get("/session/active", response_model=Optional[SessionInfo])
async def get_active_session(device_id: Optional[str] = None):
"""Get information about the active session for a device.
Uses PM3Service (via SessionManager) for session info.
Args:
device_id: Optional device ID. If None, returns any active session.
"""
# Access session manager through container for consistency
session = container.session_manager.get_active_session(device_id)
if not session:
return None
import time
from .. import config
time_remaining = config.SESSION_TIMEOUT - (time.time() - session.last_activity)
return SessionInfo(
session_id=session.session_id,
device_id=session.device_id,
client_ip=session.client_ip,
created_at=session.created_at,
last_activity=session.last_activity,
time_remaining=max(0, time_remaining)
)
@router.get("/sessions/all")
async def get_all_sessions():
"""Get all active sessions across all devices.
Returns a list of all active sessions with their device IDs.
"""
result = container.pm3_service.get_all_sessions()
return result.data
@router.get("/info", response_model=SystemInfo)
async def get_system_info():
"""Get system information.
Refactored to use SystemService for business logic.
"""
from ..services.container import container
result = await container.system_service.get_info()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
cpu_data = result.data["cpu"]
memory_data = result.data["memory"]
disk_data = result.data["disk"]
# Build per-core CPU info
per_core = [
CPUCoreInfo(
core_id=c["core_id"],
percent=c["percent"],
online=c.get("online", True)
)
for c in cpu_data.get("per_core", [])
]
cpu_info = CPUInfo(
count=cpu_data.get("count", 0),
percent=cpu_data.get("percent", 0.0),
temperature=cpu_data.get("temperature"),
per_core=per_core,
load_average=cpu_data.get("load_average")
)
return SystemInfo(
hostname=result.data["hostname"],
uptime=result.data["uptime"],
cpu_temp=cpu_data.get("temperature"),
cpu=cpu_info,
memory_used=memory_data["used"],
memory_total=memory_data["total"],
disk_used=disk_data["used"],
disk_total=disk_data["total"]
)
@router.get("/config")
async def get_config():
"""Get system configuration (non-sensitive values)."""
from .. import config
return {
"pm3_device": config.PM3_DEVICE,
"session_timeout": config.SESSION_TIMEOUT,
"wifi_mode": "auto", # TODO: Get from wifi manager
"ble_enabled": config.BLE_ENABLED,
"auth_enabled": config.AUTH_ENABLED,
"https_enabled": config.HTTPS_ENABLED
}
@router.post("/restart")
async def restart_system(delay: int = 0):
"""Restart the system.
Refactored to use SystemService for business logic.
Args:
delay: Delay in seconds before restart (default: 0)
"""
from ..services.container import container
result = await container.system_service.restart(delay=delay)
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
@router.post("/shutdown")
async def shutdown_system(delay: int = 0):
"""Initiate system shutdown.
Refactored to use SystemService for business logic.
Args:
delay: Delay in seconds before shutdown (default: 0)
"""
from ..services.container import container
result = await container.system_service.shutdown(delay=delay)
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return {"success": True, "message": result.data["message"]}
class UPSStatusResponse(BaseModel):
"""UPS status response model."""
battery_percentage: float
voltage: float
current: float
power_source: str
battery_status: str
time_remaining: Optional[int] = None
temperature: Optional[float] = None
last_updated: Optional[str] = None
is_available: bool
error_message: Optional[str] = None
class UPSThresholdsRequest(BaseModel):
"""Request to set UPS battery thresholds."""
shutdown_threshold: Optional[float] = None
warning_threshold: Optional[float] = None
class PowerRestrictionsResponse(BaseModel):
"""Power restrictions response model."""
restricted: bool
reason: Optional[str] = None
power_source: str
ups_available: bool
battery_percentage: Optional[float] = None
allow_firmware_flash: bool
allow_bootloader_flash: bool
allow_intensive_operations: bool
message: Optional[str] = None
warning: Optional[str] = None
shutdown_imminent: Optional[bool] = None
@router.get("/ups/status", response_model=UPSStatusResponse)
async def get_ups_status():
"""Get current UPS battery status."""
ups_manager = get_ups_manager()
status = await ups_manager.get_status()
return UPSStatusResponse(
battery_percentage=status.battery_percentage,
voltage=status.voltage,
current=status.current,
power_source=status.power_source.value,
battery_status=status.battery_status.value,
time_remaining=status.time_remaining,
temperature=status.temperature,
last_updated=status.last_updated,
is_available=status.is_available,
error_message=status.error_message
)
@router.post("/ups/thresholds")
async def set_ups_thresholds(request: UPSThresholdsRequest):
"""Set UPS battery thresholds for warnings and shutdown."""
ups_manager = get_ups_manager()
if request.shutdown_threshold is not None:
ups_manager.set_shutdown_threshold(request.shutdown_threshold)
if request.warning_threshold is not None:
ups_manager.set_warning_threshold(request.warning_threshold)
return {
"success": True,
"message": "UPS thresholds updated"
}
@router.post("/ups/shutdown")
async def trigger_ups_shutdown(delay: int = 30):
"""Trigger safe shutdown via UPS manager.
Args:
delay: Delay in seconds before shutdown (default: 30)
"""
ups_manager = get_ups_manager()
await ups_manager.shutdown_device(delay=delay)
return {
"success": True,
"message": f"Shutdown initiated with {delay}s delay"
}
@router.get("/power/restrictions", response_model=PowerRestrictionsResponse)
async def get_power_restrictions():
"""Get current power restrictions based on UPS/battery state.
Returns power policy information including:
- Whether operations are restricted
- Current power source (AC, battery, or assumed AC if no UPS)
- Battery level (if UPS present)
- Which operations are allowed (firmware flash, bootloader flash, etc.)
- User-friendly messages and warnings
This endpoint is critical for determining whether power-intensive
operations (like firmware flashing) should be allowed.
If UPS hardware is not detected, assumes stable AC power and allows
all operations (user responsibility to ensure power stability).
"""
ups_manager = get_ups_manager()
restrictions = ups_manager.get_power_restrictions()
return PowerRestrictionsResponse(**restrictions)
class PiModelResponse(BaseModel):
"""Pi model information response."""
model: str
model_short: str
total_cores: int
default_active_cores: int
min_cores: int
max_cores: int
class CPUCoresConfigResponse(BaseModel):
"""CPU cores configuration response."""
total_cores: int
online_cores: int
configured_cores: Optional[int] = None # From cmdline.txt maxcpus parameter
configurable_cores: list[int]
pi_model: Optional[PiModelResponse] = None
class SetCPUCoresRequest(BaseModel):
"""Request to set CPU cores."""
num_cores: int
persist: bool = True # Save to config for boot persistence
reboot: bool = True # Reboot after saving
@router.get("/cpu/cores", response_model=CPUCoresConfigResponse)
async def get_cpu_cores():
"""Get CPU cores configuration.
Returns information about:
- Total physical cores
- Currently online cores
- Which cores can be toggled (core 0 is always on)
- Pi model information with recommended defaults
"""
result = await container.system_service.get_cpu_cores_config()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
# Convert pi_model dict to response model if present
pi_model_data = result.data.get("pi_model")
pi_model = None
if pi_model_data:
pi_model = PiModelResponse(**pi_model_data)
return CPUCoresConfigResponse(
total_cores=result.data["total_cores"],
online_cores=result.data["online_cores"],
configured_cores=result.data.get("configured_cores"),
configurable_cores=result.data["configurable_cores"],
pi_model=pi_model
)
@router.post("/cpu/cores")
async def set_cpu_cores(request: SetCPUCoresRequest):
"""Set the number of active CPU cores.
Core 0 is always active. This endpoint enables/disables cores 1 through N.
For Pi Zero 2 W, the recommended default is 2 cores (out of 4) for
better thermal management and power efficiency.
Args:
num_cores: Number of cores to keep active (1 to max_cores)
persist: Save setting to config file (default: True)
reboot: Reboot system after saving (default: True)
"""
result = await container.system_service.set_cpu_cores(
num_cores=request.num_cores,
persist=request.persist,
reboot=request.reboot
)
if not result.success:
raise HTTPException(
status_code=400 if result.error.code == "invalid_cores" else 500,
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"],
"requested": result.data.get("requested"),
"actual": result.data.get("actual"),
"rebooting": result.data.get("rebooting", False)
}
@router.get("/pi/model", response_model=PiModelResponse)
async def get_pi_model():
"""Get Raspberry Pi model information.
Returns the detected Pi model with recommended CPU core settings.
"""
result = await container.system_service.get_pi_model()
if not result.success:
raise HTTPException(
status_code=500,
detail=result.error.message
)
return PiModelResponse(**result.data)
class BLEStatusResponse(BaseModel):
"""BLE status response model."""
enabled: bool
available: bool
advertising: bool
connected_devices: int
device_name: str
queued_notifications: int
class SendNotificationRequest(BaseModel):
"""Request to send a BLE notification."""
type: str
message: str
data: Optional[Dict] = None
@router.get("/ble/status", response_model=BLEStatusResponse)
async def get_ble_status():
"""Get BLE manager status."""
ble_manager = get_ble_manager()
status = await ble_manager.get_status()
return BLEStatusResponse(**status)
@router.post("/ble/advertising/start")
async def start_ble_advertising():
"""Start BLE advertising."""
ble_manager = get_ble_manager()
if not ble_manager.is_available():
raise HTTPException(status_code=503, detail="BLE not available")
await ble_manager.start_advertising()
return {
"success": True,
"message": "BLE advertising started"
}
@router.post("/ble/advertising/stop")
async def stop_ble_advertising():
"""Stop BLE advertising."""
ble_manager = get_ble_manager()
await ble_manager.stop_advertising()
return {
"success": True,
"message": "BLE advertising stopped"
}
@router.post("/ble/notify")
async def send_ble_notification(request: SendNotificationRequest):
"""Send a BLE notification to connected devices."""
ble_manager = get_ble_manager()
if not ble_manager.is_available():
raise HTTPException(status_code=503, detail="BLE not available")
from ..managers.ble_manager import NotificationType
# Validate notification type
try:
notification_type = NotificationType(request.type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid notification type: {request.type}")
await ble_manager.send_notification(
notification_type=notification_type,
message=request.message,
data=request.data
)
return {
"success": True,
"message": "Notification sent"
}

View File

@@ -0,0 +1,205 @@
"""Update management API endpoints.
Refactored to use UpdateService for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..services.container import container
from ..managers.ble_manager import get_ble_manager, NotificationType
router = APIRouter()
class UpdateCheckResponse(BaseModel):
"""Response model for update check."""
update_available: bool
current_version: str
latest_version: Optional[str] = None
release_date: Optional[str] = None
changelog: Optional[str] = None
is_prerelease: bool = False
download_size: Optional[int] = None
message: Optional[str] = None
class UpdateProgressResponse(BaseModel):
"""Response model for update progress."""
status: str
current_version: str
available_version: Optional[str] = None
download_progress: float = 0.0
error_message: Optional[str] = None
last_check: Optional[str] = None
class ReleaseNotesRequest(BaseModel):
"""Request model for release notes."""
version: Optional[str] = None
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = {
"update_check_error": 500,
"no_update_available": 400,
"download_failed": 500,
"update_download_error": 500,
"no_update_downloaded": 400,
"installation_failed": 500,
"update_install_error": 500,
"progress_error": 500,
"release_notes_error": 500,
"check_download_error": 500,
"full_update_error": 500,
}
return codes.get(error_code, 500)
@router.get("/check", response_model=UpdateCheckResponse)
async def check_for_updates():
"""Check for available updates.
Uses UpdateService for business logic.
"""
result = await container.update_service.check_for_updates()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Send BLE notification if update is available
if result.data.get("update_available"):
try:
ble_manager = get_ble_manager()
await ble_manager.send_notification(
NotificationType.UPDATE_AVAILABLE,
f"Update available: v{result.data['latest_version']}",
{"version": result.data["latest_version"]}
)
except Exception:
# BLE notification failure shouldn't affect the response
pass
return UpdateCheckResponse(**result.data)
@router.get("/progress", response_model=UpdateProgressResponse)
async def get_update_progress():
"""Get current update progress.
Uses UpdateService for business logic.
"""
result = await container.update_service.get_progress()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return UpdateProgressResponse(**result.data)
@router.post("/download")
async def download_update():
"""Download the available update.
Uses UpdateService for business logic.
"""
result = await container.update_service.download_update()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"message": result.data["message"]}
@router.post("/install")
async def install_update():
"""Install the downloaded update.
Uses UpdateService for business logic.
"""
result = await container.update_service.install_update()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
# Send BLE notification
try:
ble_manager = get_ble_manager()
await ble_manager.send_notification(
NotificationType.UPDATE_COMPLETE,
"Update installed successfully",
{"restart_required": True}
)
except Exception:
# BLE notification failure shouldn't affect the response
pass
return {
"message": result.data["message"],
"restart_required": result.data.get("requires_restart", True)
}
@router.post("/release-notes", response_model=dict)
async def get_release_notes(request: ReleaseNotesRequest):
"""Get release notes for a specific version.
Uses UpdateService for business logic.
Args:
request: Version to get notes for (latest if not specified)
"""
result = await container.update_service.get_release_notes(request.version)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"version": result.data["version"],
"notes": result.data["release_notes"]
}
@router.get("/current-version")
async def get_current_version():
"""Get current system version.
Uses UpdateService for business logic.
"""
result = await container.update_service.get_progress()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"version": result.data["current_version"],
"last_check": result.data["last_check"]
}

View File

@@ -0,0 +1,319 @@
"""WiFi management API endpoints.
Refactored to use WiFiService for all business logic.
Endpoints are now thin adapters that convert HTTP requests/responses.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from ..services.container import container
router = APIRouter()
class WiFiStatusResponse(BaseModel):
"""WiFi status response."""
mode: str
interfaces: List[dict]
current_ssid: Optional[str]
current_ip: Optional[str]
ap_ssid: Optional[str]
ap_ip: Optional[str]
supports_dual: bool
class WiFiNetworkResponse(BaseModel):
"""WiFi network response."""
ssid: str
bssid: str
signal_strength: int
frequency: int
encrypted: bool
in_use: bool
class SetModeRequest(BaseModel):
"""Request to set WiFi mode."""
mode: str # "ap", "client", "dual", "auto", "off"
class ConnectRequest(BaseModel):
"""Request to connect to network."""
ssid: str
password: Optional[str] = None
interface: Optional[str] = None
hidden: bool = False
def _service_error_to_http_status(error_code: str) -> int:
"""Map service error codes to HTTP status codes.
Args:
error_code: Service error code
Returns:
HTTP status code
"""
codes = {
"wifi_status_error": 500,
"wifi_scan_error": 500,
"connection_failed": 503,
"wifi_connect_error": 500,
"disconnect_failed": 500,
"wifi_disconnect_error": 500,
"invalid_mode": 400,
"mode_not_supported": 400,
"mode_change_failed": 500,
"wifi_mode_error": 500,
"saved_networks_error": 500,
"network_not_found": 404,
"forget_network_error": 500,
}
return codes.get(error_code, 500)
@router.get("/status", response_model=WiFiStatusResponse)
async def get_wifi_status():
"""Get current WiFi status and available interfaces.
Uses WiFiService for business logic.
"""
result = await container.wifi_service.get_status()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
data = result.data
return WiFiStatusResponse(
mode=data["mode"],
interfaces=data["interfaces"],
current_ssid=data["client"]["ssid"] if data.get("client") else None,
current_ip=data["client"]["ip"] if data.get("client") else None,
ap_ssid=data["access_point"]["ssid"],
ap_ip=data["access_point"]["ip"],
supports_dual=data["supports_dual"]
)
@router.get("/scan", response_model=List[WiFiNetworkResponse])
async def scan_networks(interface: Optional[str] = None):
"""Scan for available WiFi networks.
Uses WiFiService for business logic.
Args:
interface: Optional interface to scan with
"""
result = await container.wifi_service.scan_networks(interface)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return [
WiFiNetworkResponse(**net)
for net in result.data["networks"]
]
@router.post("/mode")
async def set_wifi_mode(request: SetModeRequest):
"""Set WiFi operation mode.
Uses WiFiService for business logic.
Args:
request: Mode to set (ap, client, dual, auto, off)
"""
result = await container.wifi_service.set_mode(request.mode)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"],
"mode": result.data["mode"]
}
@router.post("/connect")
async def connect_to_network(request: ConnectRequest):
"""Connect to a WiFi network.
Uses WiFiService for business logic.
Args:
request: Connection details (SSID, password, interface, hidden)
"""
result = await container.wifi_service.connect(
ssid=request.ssid,
password=request.password,
interface=request.interface,
hidden=request.hidden
)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"],
"ssid": result.data["ssid"],
"ip": result.data.get("ip")
}
@router.get("/interfaces")
async def get_interfaces():
"""Get available WiFi interfaces.
Uses WiFiService for business logic.
"""
result = await container.wifi_service.get_status()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"interfaces": result.data["interfaces"],
"supports_dual": result.data["supports_dual"]
}
@router.post("/disconnect")
async def disconnect_from_network(interface: Optional[str] = None):
"""Disconnect from current network.
Uses WiFiService for business logic.
Args:
interface: Interface to disconnect (optional)
"""
result = await container.wifi_service.disconnect(interface)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"]
}
@router.get("/saved")
async def get_saved_networks():
"""Get list of saved networks.
Uses WiFiService for business logic.
"""
result = await container.wifi_service.get_saved_networks()
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {"networks": result.data["networks"]}
@router.delete("/saved/{ssid}")
async def forget_network(ssid: str):
"""Forget a saved network.
Uses WiFiService for business logic.
Args:
ssid: SSID of network to forget
"""
result = await container.wifi_service.forget_network(ssid)
if not result.success:
raise HTTPException(
status_code=_service_error_to_http_status(result.error.code),
detail=result.error.message
)
return {
"success": True,
"message": result.data["message"]
}
@router.post("/static-ip")
async def set_static_ip(
interface: str,
ip_address: str,
netmask: str = "255.255.255.0",
gateway: Optional[str] = None,
dns: Optional[List[str]] = None,
):
"""Set static IP for interface.
NOTE: This is a direct manager operation (not yet in WiFiService).
TODO: Move to WiFiService in future iteration.
Args:
interface: Interface name
ip_address: Static IP address
netmask: Network mask (default: 255.255.255.0)
gateway: Gateway IP (optional)
dns: DNS servers (optional)
"""
try:
success = await container.wifi_manager.set_static_ip(
interface, ip_address, netmask, gateway, dns
)
if not success:
raise HTTPException(status_code=500, detail="Failed to set static IP")
return {
"success": True,
"message": f"Static IP {ip_address} set for {interface}",
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to set static IP: {e}")
@router.post("/dhcp")
async def enable_dhcp(interface: str):
"""Enable DHCP for interface.
NOTE: This is a direct manager operation (not yet in WiFiService).
TODO: Move to WiFiService in future iteration.
Args:
interface: Interface name
"""
try:
success = await container.wifi_manager.enable_dhcp(interface)
if not success:
raise HTTPException(status_code=500, detail="Failed to enable DHCP")
return {
"success": True,
"message": f"DHCP enabled for {interface}",
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to enable DHCP: {e}")

View File

@@ -0,0 +1,48 @@
"""BLE GATT server implementation for Dangerous Pi.
This module provides Bluetooth Low Energy (BLE) GATT server functionality
that reuses the service layer for business logic, ensuring consistency
with the REST API.
Components:
- DangerousPiGATTServer: GATT handlers that call service layer
- BlueZGATTAdapter: Bridges bless library to GATT handlers
- Characteristic UUIDs: Service and characteristic definitions
"""
from .gatt_server import DangerousPiGATTServer
from .characteristics import (
PM3CharacteristicUUIDs,
WiFiCharacteristicUUIDs,
SystemCharacteristicUUIDs,
UpdateCharacteristicUUIDs,
)
# BlueZ adapter (requires bless library)
try:
from .bluez_adapter import (
BlueZGATTAdapter,
get_ble_adapter,
start_ble_server,
stop_ble_server,
)
BLESS_AVAILABLE = True
except ImportError:
BlueZGATTAdapter = None
get_ble_adapter = None
start_ble_server = None
stop_ble_server = None
BLESS_AVAILABLE = False
__all__ = [
"DangerousPiGATTServer",
"PM3CharacteristicUUIDs",
"WiFiCharacteristicUUIDs",
"SystemCharacteristicUUIDs",
"UpdateCharacteristicUUIDs",
"BlueZGATTAdapter",
"get_ble_adapter",
"start_ble_server",
"stop_ble_server",
"BLESS_AVAILABLE",
]

View File

@@ -0,0 +1,457 @@
"""BlueZ GATT adapter using the bless library.
This module bridges our GATT server handlers to BlueZ via bless,
enabling BLE peripheral functionality on Linux.
Architecture:
bless BLEServer (handles BlueZ D-Bus)
|
BlueZGATTAdapter (this file - bridges handlers)
|
DangerousPiGATTServer (handlers call service layer)
|
Service Layer (business logic)
"""
import asyncio
import logging
import sys
import threading
from typing import Dict, Optional, Any, Union
from bless import (
BlessServer,
BlessGATTCharacteristic,
GATTCharacteristicProperties,
GATTAttributePermissions,
)
from .gatt_server import DangerousPiGATTServer
from .characteristics import (
PM3CharacteristicUUIDs,
WiFiCharacteristicUUIDs,
SystemCharacteristicUUIDs,
UpdateCharacteristicUUIDs,
)
logger = logging.getLogger(__name__)
# Device name for BLE advertising
DEFAULT_DEVICE_NAME = "Dangerous-Pi"
class BlueZGATTAdapter:
"""Adapter connecting bless BLE server to our GATT handlers.
This class:
- Initializes the bless BLE server
- Registers all services and characteristics via GATT dictionary
- Routes read/write requests to DangerousPiGATTServer handlers
- Sends notifications via bless
"""
def __init__(self, device_name: str = DEFAULT_DEVICE_NAME):
"""Initialize the BlueZ GATT adapter.
Args:
device_name: BLE device name for advertising
"""
self.device_name = device_name
self.server: Optional[BlessServer] = None
self.gatt_server = DangerousPiGATTServer()
self._is_running = False
self._loop: Optional[asyncio.AbstractEventLoop] = None
# Platform-specific trigger for async coordination
self._trigger: Union[asyncio.Event, threading.Event]
if sys.platform in ["darwin", "win32"]:
self._trigger = threading.Event()
else:
self._trigger = asyncio.Event()
@property
def is_running(self) -> bool:
"""Check if BLE server is running."""
return self._is_running
def _build_gatt_dict(self) -> Dict:
"""Build the GATT dictionary for bless.
Returns:
Dictionary mapping service UUIDs to characteristic definitions
"""
return {
# PM3 Service
PM3CharacteristicUUIDs.SERVICE: {
PM3CharacteristicUUIDs.COMMAND_WRITE: {
"Properties": GATTCharacteristicProperties.write,
"Permissions": GATTAttributePermissions.writeable,
"Value": None,
},
PM3CharacteristicUUIDs.COMMAND_RESULT: {
"Properties": (
GATTCharacteristicProperties.read |
GATTCharacteristicProperties.notify
),
"Permissions": GATTAttributePermissions.readable,
"Value": bytearray(b'{}'),
},
PM3CharacteristicUUIDs.STATUS: {
"Properties": GATTCharacteristicProperties.read,
"Permissions": GATTAttributePermissions.readable,
"Value": bytearray(b'{"connected": false}'),
},
PM3CharacteristicUUIDs.SESSION_CREATE: {
"Properties": GATTCharacteristicProperties.write,
"Permissions": GATTAttributePermissions.writeable,
"Value": None,
},
PM3CharacteristicUUIDs.SESSION_RELEASE: {
"Properties": GATTCharacteristicProperties.write,
"Permissions": GATTAttributePermissions.writeable,
"Value": None,
},
},
# WiFi Service
WiFiCharacteristicUUIDs.SERVICE: {
WiFiCharacteristicUUIDs.STATUS: {
"Properties": GATTCharacteristicProperties.read,
"Permissions": GATTAttributePermissions.readable,
"Value": bytearray(b'{"mode": "unknown"}'),
},
WiFiCharacteristicUUIDs.SCAN: {
"Properties": (
GATTCharacteristicProperties.write |
GATTCharacteristicProperties.notify
),
"Permissions": (
GATTAttributePermissions.readable |
GATTAttributePermissions.writeable
),
"Value": None,
},
WiFiCharacteristicUUIDs.CONNECT: {
"Properties": GATTCharacteristicProperties.write,
"Permissions": GATTAttributePermissions.writeable,
"Value": None,
},
WiFiCharacteristicUUIDs.MODE: {
"Properties": (
GATTCharacteristicProperties.read |
GATTCharacteristicProperties.write
),
"Permissions": (
GATTAttributePermissions.readable |
GATTAttributePermissions.writeable
),
"Value": bytearray(b'client'),
},
},
# System Service
SystemCharacteristicUUIDs.SERVICE: {
SystemCharacteristicUUIDs.INFO: {
"Properties": (
GATTCharacteristicProperties.read |
GATTCharacteristicProperties.notify
),
"Permissions": GATTAttributePermissions.readable,
"Value": bytearray(b'{}'),
},
SystemCharacteristicUUIDs.SHUTDOWN: {
"Properties": GATTCharacteristicProperties.write,
"Permissions": GATTAttributePermissions.writeable,
"Value": None,
},
SystemCharacteristicUUIDs.RESTART: {
"Properties": GATTCharacteristicProperties.write,
"Permissions": GATTAttributePermissions.writeable,
"Value": None,
},
},
# Update Service
UpdateCharacteristicUUIDs.SERVICE: {
UpdateCharacteristicUUIDs.CHECK: {
"Properties": (
GATTCharacteristicProperties.write |
GATTCharacteristicProperties.notify
),
"Permissions": (
GATTAttributePermissions.readable |
GATTAttributePermissions.writeable
),
"Value": None,
},
UpdateCharacteristicUUIDs.PROGRESS: {
"Properties": (
GATTCharacteristicProperties.read |
GATTCharacteristicProperties.notify
),
"Permissions": GATTAttributePermissions.readable,
"Value": bytearray(b'{"progress": 0}'),
},
},
}
def _on_read(self, characteristic: BlessGATTCharacteristic) -> bytearray:
"""Handle BLE read request.
Note: This callback is called synchronously from the D-Bus event handler.
We cannot block on async operations here as it would deadlock the event loop.
Instead, we return cached values and schedule async updates in the background.
Args:
characteristic: The characteristic being read
Returns:
Characteristic value as bytearray
"""
uuid = str(characteristic.uuid).lower()
logger.info("BLE Read request for characteristic %s", uuid)
# Return the current cached value (synchronously)
# Async handlers update these values in the background
if characteristic.value:
logger.info("Read returning cached: %s", characteristic.value[:50] if len(characteristic.value) > 50 else characteristic.value)
return characteristic.value
# Return default JSON if no cached value
default_value = bytearray(b'{"status": "initializing"}')
logger.info("Read returning default: %s", default_value)
return default_value
def _on_write(self, characteristic: BlessGATTCharacteristic, value: Any):
"""Handle BLE write request.
Args:
characteristic: The characteristic being written
value: Value being written
"""
uuid = str(characteristic.uuid).lower() # Use lowercase to match our UUID format
logger.info("BLE Write request for characteristic %s: %s", uuid, value)
# Update the characteristic value
characteristic.value = value
handler = self.gatt_server.get_characteristic_handler(uuid)
if handler and handler.write_handler:
try:
# Convert value to bytes if needed
if isinstance(value, bytearray):
data = bytes(value)
elif isinstance(value, bytes):
data = value
else:
data = bytes(value)
# Run async handler in event loop
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
handler.write_handler(data),
self._loop
)
except Exception as e:
logger.error("Error handling write for %s: %s", uuid, e)
def _on_subscribe(self, characteristic: BlessGATTCharacteristic, **kwargs):
"""Handle subscription to characteristic notifications."""
logger.info("Client subscribed to %s", characteristic.uuid)
def _on_unsubscribe(self, characteristic: BlessGATTCharacteristic, **kwargs):
"""Handle unsubscription from characteristic notifications."""
logger.info("Client unsubscribed from %s", characteristic.uuid)
async def start(self) -> bool:
"""Start the BLE GATT server.
Returns:
True if started successfully, False otherwise
"""
if self._is_running:
logger.warning("BLE server already running")
return True
try:
self._loop = asyncio.get_running_loop()
# Create bless server
self.server = BlessServer(name=self.device_name, loop=self._loop)
# Set up callbacks (bless 0.3.0+ API)
self.server.read_request_func = self._on_read
self.server.write_request_func = self._on_write
# Build and add GATT structure
gatt = self._build_gatt_dict()
await self.server.add_gatt(gatt)
# Start the server (begins advertising)
await self.server.start()
# Start GATT server handlers
await self.gatt_server.start()
# Register notification callbacks
self._setup_notification_callbacks()
self._is_running = True
logger.info("BLE GATT server started, advertising as '%s'", self.device_name)
return True
except Exception as e:
logger.error("Failed to start BLE server: %s", e)
import traceback
traceback.print_exc()
self._is_running = False
return False
def _setup_notification_callbacks(self):
"""Set up notification callbacks for characteristics that support notify."""
notify_chars = [
PM3CharacteristicUUIDs.COMMAND_RESULT,
WiFiCharacteristicUUIDs.SCAN,
SystemCharacteristicUUIDs.INFO,
UpdateCharacteristicUUIDs.CHECK,
UpdateCharacteristicUUIDs.PROGRESS,
]
for uuid in notify_chars:
self._register_notification_callback(uuid)
def _register_notification_callback(self, uuid: str):
"""Register notification callback for a characteristic.
Args:
uuid: Characteristic UUID
"""
async def send_notification(value: bytes):
if self.server and self._is_running:
try:
char = self.server.get_characteristic(uuid)
if char:
char.value = bytearray(value)
service_uuid = self._get_service_uuid_for_characteristic(uuid)
# update_value is not async in bless 0.3+
self.server.update_value(service_uuid, uuid)
logger.debug("Notification sent for %s", uuid)
except Exception as e:
logger.error("Failed to send notification for %s: %s", uuid, e)
self.gatt_server.register_notification_callback(uuid, send_notification)
def _get_service_uuid_for_characteristic(self, char_uuid: str) -> str:
"""Get the service UUID that contains a characteristic.
Args:
char_uuid: Characteristic UUID
Returns:
Service UUID
"""
# Check each service's characteristics
if char_uuid in [
PM3CharacteristicUUIDs.COMMAND_WRITE,
PM3CharacteristicUUIDs.COMMAND_RESULT,
PM3CharacteristicUUIDs.STATUS,
PM3CharacteristicUUIDs.SESSION_CREATE,
PM3CharacteristicUUIDs.SESSION_RELEASE,
]:
return PM3CharacteristicUUIDs.SERVICE
elif char_uuid in [
WiFiCharacteristicUUIDs.STATUS,
WiFiCharacteristicUUIDs.SCAN,
WiFiCharacteristicUUIDs.CONNECT,
WiFiCharacteristicUUIDs.MODE,
]:
return WiFiCharacteristicUUIDs.SERVICE
elif char_uuid in [
SystemCharacteristicUUIDs.INFO,
SystemCharacteristicUUIDs.SHUTDOWN,
SystemCharacteristicUUIDs.RESTART,
]:
return SystemCharacteristicUUIDs.SERVICE
elif char_uuid in [
UpdateCharacteristicUUIDs.CHECK,
UpdateCharacteristicUUIDs.PROGRESS,
]:
return UpdateCharacteristicUUIDs.SERVICE
else:
return PM3CharacteristicUUIDs.SERVICE # Default
async def stop(self):
"""Stop the BLE GATT server."""
if not self._is_running:
return
try:
await self.gatt_server.stop()
if self.server:
await self.server.stop()
self.server = None
self._is_running = False
logger.info("BLE server stopped")
except Exception as e:
logger.error("Error stopping BLE server: %s", e)
async def send_notification(self, uuid: str, value: bytes) -> bool:
"""Send a notification for a characteristic.
Args:
uuid: Characteristic UUID
value: Notification value
Returns:
True if sent successfully
"""
if not self.server or not self._is_running:
return False
try:
char = self.server.get_characteristic(uuid)
if char:
char.value = bytearray(value)
service_uuid = self._get_service_uuid_for_characteristic(uuid)
# update_value is not async in bless 0.3+
self.server.update_value(service_uuid, uuid)
return True
except Exception as e:
logger.error("Error sending notification for %s: %s", uuid, e)
return False
# Singleton instance for application use
_adapter_instance: Optional[BlueZGATTAdapter] = None
def get_ble_adapter() -> BlueZGATTAdapter:
"""Get or create the singleton BLE adapter instance.
Returns:
BlueZGATTAdapter instance
"""
global _adapter_instance
if _adapter_instance is None:
_adapter_instance = BlueZGATTAdapter()
return _adapter_instance
async def start_ble_server(device_name: str = DEFAULT_DEVICE_NAME) -> bool:
"""Start the BLE GATT server.
Args:
device_name: BLE device name for advertising
Returns:
True if started successfully
"""
adapter = get_ble_adapter()
adapter.device_name = device_name
return await adapter.start()
async def stop_ble_server():
"""Stop the BLE GATT server."""
adapter = get_ble_adapter()
await adapter.stop()

View File

@@ -0,0 +1,112 @@
"""GATT Characteristic UUIDs for Dangerous Pi BLE interface.
This module defines the UUIDs for all GATT services and characteristics
used by the Dangerous Pi BLE interface.
UUID Namespace: Dangerous Pi uses custom 128-bit UUIDs.
Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12 hex chars)
Services are differentiated by the 5th group:
- PM3: 0000xxxx (0000-000f)
- WiFi: 0001xxxx (0010-001f)
- System: 0002xxxx (0020-002f)
- Update: 0003xxxx (0030-003f)
"""
from dataclasses import dataclass
def _uuid(suffix: str) -> str:
"""Generate a Dangerous Pi UUID with the given 4-char suffix.
Args:
suffix: 4-character hex suffix (e.g., "0001", "0010")
Returns:
Full 128-bit UUID string
"""
return f"d4c3b2a1-0000-1000-8000-00805f9b{suffix}"
@dataclass
class PM3CharacteristicUUIDs:
"""PM3 GATT Service and Characteristics.
Service for Proxmark3 operations (command execution, status).
"""
# Service UUID
SERVICE = _uuid("0000")
# Characteristics
COMMAND_WRITE = _uuid("0001") # Write: Execute PM3 command
COMMAND_RESULT = _uuid("0002") # Notify: Command result
STATUS = _uuid("0003") # Read: Get PM3 status
SESSION_CREATE = _uuid("0004") # Write: Create session
SESSION_RELEASE = _uuid("0005") # Write: Release session
SESSION_INFO = _uuid("0006") # Read: Get session info
@dataclass
class WiFiCharacteristicUUIDs:
"""WiFi GATT Service and Characteristics.
Service for WiFi network management (scan, connect, status).
"""
# Service UUID
SERVICE = _uuid("0010")
# Characteristics
STATUS = _uuid("0011") # Read: Get WiFi status
SCAN = _uuid("0012") # Write: Trigger scan, Notify: Results
CONNECT = _uuid("0013") # Write: Connect to network
DISCONNECT = _uuid("0014") # Write: Disconnect
MODE = _uuid("0015") # Read/Write: WiFi mode
SAVED_NETWORKS = _uuid("0016") # Read: Get saved networks
FORGET_NETWORK = _uuid("0017") # Write: Forget network
@dataclass
class SystemCharacteristicUUIDs:
"""System GATT Service and Characteristics.
Service for system operations (info, shutdown, restart).
"""
# Service UUID
SERVICE = _uuid("0020")
# Characteristics
INFO = _uuid("0021") # Read: Get system info
SHUTDOWN = _uuid("0022") # Write: Initiate shutdown
RESTART = _uuid("0023") # Write: Initiate restart
LOGS = _uuid("0024") # Read: Get service logs
@dataclass
class UpdateCharacteristicUUIDs:
"""Update GATT Service and Characteristics.
Service for software update management.
"""
# Service UUID
SERVICE = _uuid("0030")
# Characteristics
CHECK = _uuid("0031") # Write: Check for updates, Notify: Result
DOWNLOAD = _uuid("0032") # Write: Download update
INSTALL = _uuid("0033") # Write: Install update
PROGRESS = _uuid("0034") # Read/Notify: Update progress
RELEASE_NOTES = _uuid("0035") # Read: Get release notes
# Characteristic properties
class CharacteristicProperties:
"""Standard GATT characteristic properties."""
READ = "read"
WRITE = "write"
WRITE_WITHOUT_RESPONSE = "write-without-response"
NOTIFY = "notify"
INDICATE = "indicate"
# Characteristic descriptors
CHARACTERISTIC_USER_DESCRIPTION_UUID = "00002901-0000-1000-8000-00805f9b34fb"
CLIENT_CHARACTERISTIC_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb"

View File

@@ -0,0 +1,719 @@
"""GATT Server implementation for Dangerous Pi.
This GATT server provides BLE access to all Dangerous Pi functionality by
reusing the service layer. This ensures identical behavior between REST API
and BLE interface - NO CODE DUPLICATION!
Architecture:
BLE GATT Characteristic Handler
Service Layer (PM3Service, WiFiService, etc.)
Managers/Workers (PM3Worker, WiFiManager, etc.)
The handlers are thin adapters, just like REST endpoints.
"""
import asyncio
import json
import logging
from typing import Dict, Any, Optional, Callable
from dataclasses import dataclass
from ..services.container import container
from .characteristics import (
PM3CharacteristicUUIDs,
WiFiCharacteristicUUIDs,
SystemCharacteristicUUIDs,
UpdateCharacteristicUUIDs,
)
logger = logging.getLogger(__name__)
@dataclass
class CharacteristicHandler:
"""Configuration for a GATT characteristic handler."""
uuid: str
properties: list # ["read", "write", "notify"]
read_handler: Optional[Callable] = None
write_handler: Optional[Callable] = None
description: str = ""
class DangerousPiGATTServer:
"""GATT server for Dangerous Pi BLE interface.
This server exposes Dangerous Pi functionality over BLE by reusing
the service layer. All business logic comes from services, ensuring
consistency with the REST API.
Key Features:
- Uses ServiceContainer for all operations (same as REST!)
- Converts BLE data formats to/from service calls
- Sends notifications for async operations
- Zero business logic duplication
"""
def __init__(self):
"""Initialize GATT server."""
self.is_running = False
self._notification_callbacks: Dict[str, Callable] = {}
self._characteristic_handlers: Dict[str, CharacteristicHandler] = {}
# Register all characteristic handlers
self._register_pm3_characteristics()
self._register_wifi_characteristics()
self._register_system_characteristics()
self._register_update_characteristics()
logger.info("GATT server initialized with %d characteristics",
len(self._characteristic_handlers))
# ========================================================================
# PM3 Characteristic Handlers
# ========================================================================
def _register_pm3_characteristics(self):
"""Register PM3 GATT characteristics.
All handlers delegate to PM3Service - NO business logic here!
"""
self._characteristic_handlers.update({
PM3CharacteristicUUIDs.COMMAND_WRITE: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.COMMAND_WRITE,
properties=["write"],
write_handler=self._handle_pm3_command_write,
description="Execute PM3 command"
),
PM3CharacteristicUUIDs.STATUS: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.STATUS,
properties=["read"],
read_handler=self._handle_pm3_status_read,
description="Get PM3 status"
),
PM3CharacteristicUUIDs.SESSION_CREATE: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.SESSION_CREATE,
properties=["write"],
write_handler=self._handle_session_create_write,
description="Create PM3 session"
),
PM3CharacteristicUUIDs.SESSION_RELEASE: CharacteristicHandler(
uuid=PM3CharacteristicUUIDs.SESSION_RELEASE,
properties=["write"],
write_handler=self._handle_session_release_write,
description="Release PM3 session"
),
})
async def _handle_pm3_command_write(self, value: bytes) -> Dict[str, Any]:
"""Handle PM3 command execution via BLE.
This is a THIN ADAPTER - delegates to PM3Service!
Args:
value: JSON bytes: {"command": "hw version", "session_id": "..."}
Returns:
Response dict to send via notification
"""
try:
# Parse BLE request
data = json.loads(value.decode('utf-8'))
command = data.get("command")
session_id = data.get("session_id")
if not command:
return {
"success": False,
"error": {"code": "invalid_request", "message": "Command required"}
}
# ✅ REUSE PM3Service - same logic as REST API!
result = await container.pm3_service.execute_command(
command=command,
session_id=session_id
)
# Convert service result to BLE response
if result.success:
response = {
"success": True,
"output": result.data["output"],
"command": result.data["command"]
}
else:
response = {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
# Send notification with result
await self._notify_characteristic(
PM3CharacteristicUUIDs.COMMAND_RESULT,
json.dumps(response).encode('utf-8')
)
return response
except json.JSONDecodeError:
return {
"success": False,
"error": {"code": "invalid_json", "message": "Invalid JSON"}
}
except Exception as e:
logger.error("Error handling PM3 command: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
async def _handle_pm3_status_read(self) -> bytes:
"""Handle PM3 status read via BLE.
This is a THIN ADAPTER - delegates to PM3Service!
Returns:
JSON bytes with PM3 status
"""
try:
# ✅ REUSE PM3Service - same logic as REST API!
result = await container.pm3_service.get_status()
if result.success:
# Handle both single-device and multi-device responses
if "devices" in result.data:
# Multi-device mode: summarize status
devices = result.data["devices"]
connected_count = sum(1 for d in devices if d.get("connected"))
response = {
"success": True,
"device_count": len(devices),
"connected_count": connected_count,
"devices": devices
}
else:
# Legacy single-device mode
response = {
"success": True,
"connected": result.data.get("connected", False),
"device_path": result.data.get("device_path"),
"version": result.data.get("version"),
"session_active": result.data.get("session_active", False)
}
else:
response = {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
return json.dumps(response).encode('utf-8')
except Exception as e:
logger.error("Error getting PM3 status: %s", e)
return json.dumps({
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}).encode('utf-8')
async def _handle_session_create_write(self, value: bytes) -> Dict[str, Any]:
"""Handle session creation via BLE.
Args:
value: JSON bytes: {"force_takeover": false}
"""
try:
data = json.loads(value.decode('utf-8'))
force_takeover = data.get("force_takeover", False)
# ✅ REUSE PM3Service
result = container.pm3_service.create_session(
force_takeover=force_takeover
)
if result.success:
return {
"success": True,
"session_id": result.data["session_id"]
}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error creating session: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
async def _handle_session_release_write(self, value: bytes) -> Dict[str, Any]:
"""Handle session release via BLE.
Args:
value: JSON bytes: {"session_id": "..."}
"""
try:
data = json.loads(value.decode('utf-8'))
session_id = data.get("session_id")
if not session_id:
return {
"success": False,
"error": {"code": "invalid_request", "message": "Session ID required"}
}
# ✅ REUSE PM3Service
result = container.pm3_service.release_session(session_id)
if result.success:
return {"success": True, "message": result.data["message"]}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error releasing session: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
# ========================================================================
# WiFi Characteristic Handlers
# ========================================================================
def _register_wifi_characteristics(self):
"""Register WiFi GATT characteristics."""
self._characteristic_handlers.update({
WiFiCharacteristicUUIDs.STATUS: CharacteristicHandler(
uuid=WiFiCharacteristicUUIDs.STATUS,
properties=["read"],
read_handler=self._handle_wifi_status_read,
description="Get WiFi status"
),
WiFiCharacteristicUUIDs.SCAN: CharacteristicHandler(
uuid=WiFiCharacteristicUUIDs.SCAN,
properties=["write", "notify"],
write_handler=self._handle_wifi_scan_write,
description="Scan for WiFi networks"
),
WiFiCharacteristicUUIDs.CONNECT: CharacteristicHandler(
uuid=WiFiCharacteristicUUIDs.CONNECT,
properties=["write"],
write_handler=self._handle_wifi_connect_write,
description="Connect to WiFi network"
),
WiFiCharacteristicUUIDs.MODE: CharacteristicHandler(
uuid=WiFiCharacteristicUUIDs.MODE,
properties=["read", "write"],
read_handler=self._handle_wifi_mode_read,
write_handler=self._handle_wifi_mode_write,
description="WiFi mode"
),
})
async def _handle_wifi_status_read(self) -> bytes:
"""Handle WiFi status read via BLE."""
try:
# ✅ REUSE WiFiService
result = await container.wifi_service.get_status()
if result.success:
return json.dumps(result.data).encode('utf-8')
else:
return json.dumps({
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}).encode('utf-8')
except Exception as e:
logger.error("Error getting WiFi status: %s", e)
return json.dumps({
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}).encode('utf-8')
async def _handle_wifi_scan_write(self, value: bytes) -> Dict[str, Any]:
"""Handle WiFi scan request via BLE."""
try:
data = json.loads(value.decode('utf-8')) if value else {}
interface = data.get("interface")
# ✅ REUSE WiFiService
result = await container.wifi_service.scan_networks(interface)
if result.success:
# Send scan results via notification
await self._notify_characteristic(
WiFiCharacteristicUUIDs.SCAN,
json.dumps(result.data).encode('utf-8')
)
return {"success": True, "count": result.data["count"]}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error scanning WiFi: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
async def _handle_wifi_connect_write(self, value: bytes) -> Dict[str, Any]:
"""Handle WiFi connect request via BLE."""
try:
data = json.loads(value.decode('utf-8'))
ssid = data.get("ssid")
password = data.get("password")
hidden = data.get("hidden", False)
if not ssid:
return {
"success": False,
"error": {"code": "invalid_request", "message": "SSID required"}
}
# ✅ REUSE WiFiService
result = await container.wifi_service.connect(
ssid=ssid,
password=password,
hidden=hidden
)
if result.success:
return {
"success": True,
"ssid": result.data["ssid"],
"ip": result.data.get("ip")
}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error connecting to WiFi: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
async def _handle_wifi_mode_read(self) -> bytes:
"""Handle WiFi mode read via BLE."""
try:
result = await container.wifi_service.get_status()
if result.success:
return json.dumps({"mode": result.data["mode"]}).encode('utf-8')
else:
return json.dumps({
"success": False,
"error": {"code": result.error.code, "message": result.error.message}
}).encode('utf-8')
except Exception as e:
return json.dumps({
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}).encode('utf-8')
async def _handle_wifi_mode_write(self, value: bytes) -> Dict[str, Any]:
"""Handle WiFi mode change via BLE."""
try:
data = json.loads(value.decode('utf-8'))
mode = data.get("mode")
if not mode:
return {
"success": False,
"error": {"code": "invalid_request", "message": "Mode required"}
}
# ✅ REUSE WiFiService
result = await container.wifi_service.set_mode(mode)
if result.success:
return {"success": True, "mode": result.data["mode"]}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error setting WiFi mode: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
# ========================================================================
# System Characteristic Handlers
# ========================================================================
def _register_system_characteristics(self):
"""Register System GATT characteristics."""
self._characteristic_handlers.update({
SystemCharacteristicUUIDs.INFO: CharacteristicHandler(
uuid=SystemCharacteristicUUIDs.INFO,
properties=["read"],
read_handler=self._handle_system_info_read,
description="Get system information"
),
SystemCharacteristicUUIDs.SHUTDOWN: CharacteristicHandler(
uuid=SystemCharacteristicUUIDs.SHUTDOWN,
properties=["write"],
write_handler=self._handle_system_shutdown_write,
description="Initiate system shutdown"
),
SystemCharacteristicUUIDs.RESTART: CharacteristicHandler(
uuid=SystemCharacteristicUUIDs.RESTART,
properties=["write"],
write_handler=self._handle_system_restart_write,
description="Initiate system restart"
),
})
async def _handle_system_info_read(self) -> bytes:
"""Handle system info read via BLE."""
try:
# ✅ REUSE SystemService
result = await container.system_service.get_info()
if result.success:
return json.dumps(result.data).encode('utf-8')
else:
return json.dumps({
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}).encode('utf-8')
except Exception as e:
logger.error("Error getting system info: %s", e)
return json.dumps({
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}).encode('utf-8')
async def _handle_system_shutdown_write(self, value: bytes) -> Dict[str, Any]:
"""Handle system shutdown request via BLE."""
try:
data = json.loads(value.decode('utf-8')) if value else {}
delay = data.get("delay", 0)
# ✅ REUSE SystemService
result = await container.system_service.shutdown(delay=delay)
if result.success:
return {"success": True, "message": result.data["message"]}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error initiating shutdown: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
async def _handle_system_restart_write(self, value: bytes) -> Dict[str, Any]:
"""Handle system restart request via BLE."""
try:
data = json.loads(value.decode('utf-8')) if value else {}
delay = data.get("delay", 0)
# ✅ REUSE SystemService
result = await container.system_service.restart(delay=delay)
if result.success:
return {"success": True, "message": result.data["message"]}
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error initiating restart: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
# ========================================================================
# Update Characteristic Handlers
# ========================================================================
def _register_update_characteristics(self):
"""Register Update GATT characteristics."""
self._characteristic_handlers.update({
UpdateCharacteristicUUIDs.CHECK: CharacteristicHandler(
uuid=UpdateCharacteristicUUIDs.CHECK,
properties=["write", "notify"],
write_handler=self._handle_update_check_write,
description="Check for updates"
),
UpdateCharacteristicUUIDs.PROGRESS: CharacteristicHandler(
uuid=UpdateCharacteristicUUIDs.PROGRESS,
properties=["read", "notify"],
read_handler=self._handle_update_progress_read,
description="Get update progress"
),
})
async def _handle_update_check_write(self, value: bytes) -> Dict[str, Any]:
"""Handle update check request via BLE."""
try:
# ✅ REUSE UpdateService
result = await container.update_service.check_for_updates()
if result.success:
# Send result via notification
await self._notify_characteristic(
UpdateCharacteristicUUIDs.CHECK,
json.dumps(result.data).encode('utf-8')
)
return result.data
else:
return {
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}
except Exception as e:
logger.error("Error checking for updates: %s", e)
return {
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}
async def _handle_update_progress_read(self) -> bytes:
"""Handle update progress read via BLE."""
try:
# ✅ REUSE UpdateService
result = await container.update_service.get_progress()
if result.success:
return json.dumps(result.data).encode('utf-8')
else:
return json.dumps({
"success": False,
"error": {
"code": result.error.code,
"message": result.error.message
}
}).encode('utf-8')
except Exception as e:
logger.error("Error getting update progress: %s", e)
return json.dumps({
"success": False,
"error": {"code": "internal_error", "message": str(e)}
}).encode('utf-8')
# ========================================================================
# GATT Server Management
# ========================================================================
async def start(self):
"""Start the GATT server."""
logger.info("Starting Dangerous Pi GATT server")
self.is_running = True
logger.info("GATT server started with %d characteristics",
len(self._characteristic_handlers))
async def stop(self):
"""Stop the GATT server."""
logger.info("Stopping Dangerous Pi GATT server")
self.is_running = False
logger.info("GATT server stopped")
def get_characteristic_handler(self, uuid: str) -> Optional[CharacteristicHandler]:
"""Get handler for a characteristic UUID.
Args:
uuid: Characteristic UUID
Returns:
CharacteristicHandler or None if not found
"""
return self._characteristic_handlers.get(uuid)
async def _notify_characteristic(self, uuid: str, value: bytes):
"""Send notification for a characteristic.
Args:
uuid: Characteristic UUID
value: Notification value (bytes)
"""
if uuid in self._notification_callbacks:
try:
await self._notification_callbacks[uuid](value)
logger.debug("Sent notification for %s", uuid)
except Exception as e:
logger.error("Error sending notification for %s: %s", uuid, e)
else:
logger.debug("No notification callback registered for %s", uuid)
def register_notification_callback(self, uuid: str, callback: Callable):
"""Register callback for sending notifications.
Args:
uuid: Characteristic UUID
callback: Async callable that sends the notification
"""
self._notification_callbacks[uuid] = callback
logger.debug("Registered notification callback for %s", uuid)
def get_all_characteristics(self) -> Dict[str, CharacteristicHandler]:
"""Get all registered characteristic handlers.
Returns:
Dict mapping UUID to CharacteristicHandler
"""
return self._characteristic_handlers.copy()

View File

@@ -0,0 +1,58 @@
"""Configuration settings for Dangerous Pi backend."""
import os
from pathlib import Path
# Base paths
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = BASE_DIR / "data"
LOGS_DIR = BASE_DIR / "logs"
# Database
DATABASE_PATH = DATA_DIR / "dangerous_pi.db"
# Proxmark3 settings
PM3_DEVICE = os.getenv("PM3_DEVICE", "/dev/ttyACM0")
PM3_TIMEOUT = int(os.getenv("PM3_TIMEOUT", "30"))
# Session settings
SESSION_TIMEOUT = int(os.getenv("SESSION_TIMEOUT", "300")) # 5 minutes
MAX_SESSIONS = 1
# Server settings
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
VERSION = os.getenv("VERSION", "0.1.0")
# Update settings
GITHUB_REPO = os.getenv("GITHUB_REPO", "yourusername/dangerous-pi") # TODO: Update this
UPDATE_CHECK_INTERVAL = int(os.getenv("UPDATE_CHECK_INTERVAL", "3600")) # 1 hour
# Wi-Fi settings
WLAN_INTERFACE = os.getenv("WLAN_INTERFACE", "wlan0")
USB_WLAN_INTERFACE = os.getenv("USB_WLAN_INTERFACE", "wlan1")
# UPS settings
UPS_TYPE = os.getenv("UPS_TYPE", "auto") # Options: "auto", "pisugar", "i2c", "none"
UPS_CHECK_INTERVAL = int(os.getenv("UPS_CHECK_INTERVAL", "60")) # 1 minute
# I2C UPS settings (for generic fuel gauge HATs)
UPS_I2C_ADDRESS = os.getenv("UPS_I2C_ADDRESS", "0x36")
# PiSugar UPS settings
UPS_PISUGAR_HOST = os.getenv("UPS_PISUGAR_HOST", "127.0.0.1")
UPS_PISUGAR_PORT = int(os.getenv("UPS_PISUGAR_PORT", "8423"))
# BLE settings
BLE_ENABLED = os.getenv("BLE_ENABLED", "true").lower() == "true"
BLE_DEVICE_NAME = os.getenv("BLE_DEVICE_NAME", "DangerousPi")
# Security
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true"
AUTH_USERNAME = os.getenv("AUTH_USERNAME", "admin")
AUTH_PASSWORD = os.getenv("AUTH_PASSWORD", "") # Must be set when AUTH_ENABLED=true
HTTPS_ENABLED = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
# CPU cores settings
# Set to 0 or "auto" to use model-specific defaults
# Pi Zero 2 W defaults to 2 cores (out of 4) for thermal/power management
CPU_CORES = os.getenv("CPU_CORES", "auto")

View File

@@ -0,0 +1,263 @@
"""Main FastAPI application for Dangerous Pi."""
import asyncio
import os
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from . import config
from .models.database import init_db
from .api import health, pm3, system, wifi, updates, plugins
from .api.auth import verify_credentials
from .websocket import router as ws_router
from .websocket import notifications as ws_notifications
from .managers.update_manager import get_update_manager
from .managers.ups_manager import get_ups_manager
from .managers.ble_manager import get_ble_manager, NotificationType
from .managers.plugin_manager import get_plugin_manager
from .services.container import container
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
# Startup
print(f"🚀 Starting Dangerous Pi backend...")
await init_db()
print(f"✅ Database initialized")
# Start periodic update checks
update_manager = get_update_manager()
update_task = asyncio.create_task(update_manager.start_periodic_checks())
print(f"✅ Update manager started")
# Initialize and start BLE manager
ble_manager = get_ble_manager()
await ble_manager.initialize()
if ble_manager.is_available():
await ble_manager.start_advertising()
print(f"✅ BLE manager started")
else:
print(f"⚠️ BLE not available")
# Start UPS monitoring
ups_manager = get_ups_manager()
# Register UPS event callbacks for WebSocket notifications and BLE
async def ups_event_handler(event):
"""Handle UPS events and broadcast via WebSocket and BLE."""
event_type = event.get("type")
data = event.get("data", {})
if event_type == "battery_warning":
await ws_notifications.notify_ups_warning(data["percentage"], data["threshold"])
await ble_manager.send_notification(
NotificationType.BATTERY_WARNING,
f"Battery low: {data['percentage']:.1f}%",
data
)
elif event_type == "battery_low":
await ws_notifications.notify_ups_critical(data["percentage"])
await ble_manager.send_notification(
NotificationType.BATTERY_CRITICAL,
f"Battery critical: {data['percentage']:.1f}%",
data
)
elif event_type == "battery_critical":
await ws_notifications.notify_ups_shutdown(data.get("delay", 60), data["percentage"])
await ble_manager.send_notification(
NotificationType.SHUTDOWN_INITIATED,
f"Shutdown initiated: {data['percentage']:.1f}% battery",
data
)
elif event_type == "ups_config_required":
await ws_notifications.notify_ups_config_required(
data.get("model", "Unknown"),
data.get("message", "UPS configuration required"),
data.get("hint", "")
)
await ble_manager.send_notification(
NotificationType.CONFIG_REQUIRED,
data.get("message", "UPS configuration required"),
data
)
ups_manager.register_event_callback(ups_event_handler)
ups_task = asyncio.create_task(ups_manager.start_monitoring())
print(f"✅ UPS manager started")
# Discover and load plugins
plugin_manager = get_plugin_manager()
discovered = await plugin_manager.discover_plugins()
print(f"✅ Plugin manager started ({len(discovered)} plugins discovered)")
# Start PM3 device manager for multi-device support
pm3_device_manager = container.pm3_device_manager
# Register PM3 device change callback
async def pm3_device_change_handler(device_list):
"""Handle PM3 device list changes and broadcast via WebSocket."""
devices_data = [
{
"device_id": d.device_id,
"device_path": d.device_path,
"status": d.status.value if hasattr(d.status, 'value') else d.status,
"friendly_name": d.friendly_name,
}
for d in device_list
]
await ws_notifications.notify_pm3_devices(devices_data)
pm3_device_manager.register_device_change_callback(pm3_device_change_handler)
await pm3_device_manager.start()
print(f"✅ PM3 device manager started")
# Start periodic system stats broadcasting (every 5 seconds)
async def broadcast_system_stats():
"""Periodically broadcast system stats via WebSocket."""
while True:
try:
result = await container.system_service.get_info()
if result.success:
cpu_data = result.data["cpu"]
memory_data = result.data["memory"]
await ws_notifications.notify_system_stats(
cpu_percent=cpu_data.get("percent", 0),
cpu_per_core=cpu_data.get("per_core", []),
load_average=cpu_data.get("load_average", []),
memory_percent=memory_data.get("percent", 0),
temperature=cpu_data.get("temperature")
)
except Exception as e:
print(f"Error broadcasting system stats: {e}")
await asyncio.sleep(5) # Update every 5 seconds
system_stats_task = asyncio.create_task(broadcast_system_stats())
print(f"✅ System stats broadcaster started")
yield
# Shutdown
print(f"🛑 Shutting down Dangerous Pi backend...")
# Stop PM3 device manager
await pm3_device_manager.stop()
print(f"✅ PM3 device manager stopped")
update_task.cancel()
ups_task.cancel()
system_stats_task.cancel()
try:
await update_task
except asyncio.CancelledError:
pass
try:
await ups_task
except asyncio.CancelledError:
pass
try:
await system_stats_task
except asyncio.CancelledError:
pass
# Close UPS manager I2C connection
ups_manager.close()
app = FastAPI(
title="Dangerous Pi API",
description="Backend API for Proxmark3 management on Raspberry Pi Zero 2 W",
version="0.1.0",
lifespan=lifespan
)
# CORS middleware for frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Restrict in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Auth dependency for protected routes (applied when AUTH_ENABLED=true)
auth_dependency = [Depends(verify_credentials)] if config.AUTH_ENABLED else []
# Include routers - all protected when AUTH_ENABLED=true
app.include_router(health.router, prefix="/api", tags=["health"], dependencies=auth_dependency)
app.include_router(pm3.router, prefix="/api/pm3", tags=["proxmark3"], dependencies=auth_dependency)
app.include_router(system.router, prefix="/api/system", tags=["system"], 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(plugins.router, prefix="/api/plugins", tags=["plugins"], dependencies=auth_dependency)
app.include_router(ws_router, prefix="/ws", tags=["websocket"])
# Serve frontend static files if build directory exists
# In production, frontend is built and served from /opt/dangerous-pi/app/frontend/build
# Priority: 1) check relative to working directory, 2) check absolute production path
frontend_build_paths = [
Path(__file__).parent.parent / "frontend" / "build" / "client", # Development
Path("/opt/dangerous-pi/app/frontend/build/client"), # Production
]
frontend_path = None
for path in frontend_build_paths:
if path.exists() and path.is_dir():
frontend_path = path
break
if frontend_path:
# Mount static assets (JS, CSS, images)
assets_path = frontend_path / "assets"
if assets_path.exists():
app.mount("/assets", StaticFiles(directory=str(assets_path)), name="assets")
# Cache control headers
NO_CACHE_HEADERS = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
# Serve index.html for all non-API routes (SPA support)
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
"""Serve frontend files, fall back to index.html for SPA routing."""
# Check for exact file match first
file_path = frontend_path / full_path
if file_path.exists() and file_path.is_file():
# HTML files get no-cache headers
if str(file_path).endswith('.html'):
return FileResponse(str(file_path), headers=NO_CACHE_HEADERS)
return FileResponse(str(file_path))
# Fall back to index.html for SPA routing (no-cache for HTML)
index_path = frontend_path / "index.html"
if index_path.exists():
return FileResponse(str(index_path), headers=NO_CACHE_HEADERS)
return JSONResponse(status_code=404, content={"error": "Not found"})
print(f"📁 Serving frontend from: {frontend_path}")
else:
print(f"⚠️ Frontend build not found, API-only mode")
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""Global exception handler."""
return JSONResponse(
status_code=500,
content={"error": str(exc)}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.backend.main:app",
host=config.HOST,
port=config.PORT,
reload=True
)

View File

@@ -0,0 +1 @@
"""Business logic managers."""

View File

@@ -0,0 +1,336 @@
"""BLE Manager for Dangerous Pi.
Handles Bluetooth Low Energy functionality including:
- GATT server with full PM3/WiFi/System/Update services
- Notifications for updates, backups, and battery alerts
- Uses the Pi Zero 2 W built-in Bluetooth via bless library
"""
import asyncio
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional, Dict, Any, List
from .. import config
# Try to import bless for GATT server
try:
from ..ble.bluez_adapter import BlueZGATTAdapter, get_ble_adapter
BLESS_AVAILABLE = True
except ImportError:
BLESS_AVAILABLE = False
# Fallback to dbus for basic operations
try:
import dbus
import dbus.mainloop.glib
from gi.repository import GLib
DBUS_AVAILABLE = True
except ImportError:
DBUS_AVAILABLE = False
logger = logging.getLogger(__name__)
class NotificationType(str, Enum):
"""BLE notification type enum."""
UPDATE_AVAILABLE = "update_available"
UPDATE_COMPLETE = "update_complete"
BACKUP_COMPLETE = "backup_complete"
BATTERY_WARNING = "battery_warning"
BATTERY_CRITICAL = "battery_critical"
SHUTDOWN_INITIATED = "shutdown_initiated"
PM3_STATUS = "pm3_status"
CONFIG_REQUIRED = "config_required"
@dataclass
class BLENotification:
"""BLE notification data."""
type: NotificationType
message: str
data: Dict[str, Any]
timestamp: str
class BLEManager:
"""Manages BLE functionality via Pi Zero 2 W Bluetooth.
Provides both:
- Full GATT server with PM3/WiFi/System/Update services (via bless)
- Simple notifications for system events
"""
def __init__(self):
"""Initialize the BLE manager."""
self._enabled = config.BLE_ENABLED
self._device_name = config.BLE_DEVICE_NAME
self._is_available = False
self._is_advertising = False
self._gatt_server_running = False
self._connected_devices: List[str] = []
self._notification_queue: asyncio.Queue = asyncio.Queue()
self._service_uuid = "12345678-1234-5678-1234-56789abcdef0" # Custom service UUID
self._char_uuid = "12345678-1234-5678-1234-56789abcdef1" # Notification characteristic
self._bus = None
self._adapter = None
self._gatt_adapter: Optional[BlueZGATTAdapter] = None
async def initialize(self) -> bool:
"""Initialize BLE adapter and check availability.
Returns:
True if initialization successful, False otherwise
"""
if not self._enabled:
logger.info("BLE is disabled in configuration")
return False
# Check if Bluetooth adapter is available first
has_adapter = await self._check_bluetooth_adapter()
if not has_adapter:
logger.warning("No Bluetooth adapter found")
return False
self._is_available = True
# Try to initialize GATT server with bless (preferred)
if BLESS_AVAILABLE:
try:
self._gatt_adapter = get_ble_adapter()
self._gatt_adapter.device_name = self._device_name
logger.info("BLE GATT adapter initialized (bless): %s", self._device_name)
except Exception as e:
logger.warning("Failed to initialize bless GATT adapter: %s", e)
self._gatt_adapter = None
else:
logger.info("bless library not available, using basic BLE mode")
logger.info("BLE initialized: %s", self._device_name)
return True
async def start_advertising(self):
"""Start BLE advertising and GATT server."""
if not self._is_available:
logger.warning("BLE not available, cannot start advertising")
return
try:
# Start full GATT server if available (preferred)
if self._gatt_adapter:
success = await self._gatt_adapter.start()
if success:
self._gatt_server_running = True
self._is_advertising = True
logger.info("BLE GATT server started: %s", self._device_name)
return
else:
logger.warning("Failed to start GATT server, falling back to basic mode")
# Fallback to basic advertising via bluetoothctl
await self._set_device_name(self._device_name)
await self._set_discoverable(True)
self._is_advertising = True
logger.info("BLE advertising started (basic mode): %s", self._device_name)
except Exception as e:
logger.error("Failed to start BLE advertising: %s", e)
self._is_advertising = False
async def stop_advertising(self):
"""Stop BLE advertising and GATT server."""
if not self._is_advertising:
return
try:
# Stop GATT server if running
if self._gatt_adapter and self._gatt_server_running:
await self._gatt_adapter.stop()
self._gatt_server_running = False
logger.info("BLE GATT server stopped")
else:
# Stop basic advertising
await self._set_discoverable(False)
self._is_advertising = False
logger.info("BLE advertising stopped")
except Exception as e:
logger.error("Failed to stop BLE advertising: %s", e)
async def send_notification(
self,
notification_type: NotificationType,
message: str,
data: Optional[Dict[str, Any]] = None
):
"""Send a BLE notification to connected devices.
Args:
notification_type: Type of notification
message: Human-readable message
data: Optional additional data
"""
if not self._is_available:
return
notification = BLENotification(
type=notification_type,
message=message,
data=data or {},
timestamp=datetime.utcnow().isoformat()
)
await self._notification_queue.put(notification)
# Process notification - always broadcast if GATT server is running
if self._connected_devices or self._gatt_server_running:
await self._broadcast_notification(notification)
else:
logger.debug("BLE notification queued (no devices connected): %s", message)
async def get_status(self) -> Dict[str, Any]:
"""Get BLE manager status.
Returns:
Dictionary with BLE status information
"""
return {
"enabled": self._enabled,
"available": self._is_available,
"advertising": self._is_advertising,
"gatt_server_running": self._gatt_server_running,
"gatt_available": BLESS_AVAILABLE,
"connected_devices": len(self._connected_devices),
"device_name": self._device_name,
"queued_notifications": self._notification_queue.qsize()
}
async def _check_bluetooth_adapter(self) -> bool:
"""Check if Bluetooth adapter is available.
Returns:
True if adapter is available, False otherwise
"""
try:
# Use bluetoothctl to check for adapter
process = await asyncio.create_subprocess_exec(
"bluetoothctl", "list",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
# If we get output with "Controller", we have an adapter
return b"Controller" in stdout
except FileNotFoundError:
logger.warning("bluetoothctl not found - BlueZ not installed")
return False
except Exception as e:
logger.error("Error checking Bluetooth adapter: %s", e)
return False
async def _set_device_name(self, name: str):
"""Set the Bluetooth device name.
Args:
name: Device name to set
"""
try:
process = await asyncio.create_subprocess_exec(
"bluetoothctl", "system-alias", name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await process.communicate()
except Exception as e:
logger.error("Failed to set device name: %s", e)
async def _set_discoverable(self, enabled: bool):
"""Set Bluetooth discoverable state.
Args:
enabled: True to enable discoverable, False to disable
"""
try:
command = "on" if enabled else "off"
process = await asyncio.create_subprocess_exec(
"bluetoothctl", "discoverable", command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await process.communicate()
except Exception as e:
logger.error("Failed to set discoverable: %s", e)
async def _broadcast_notification(self, notification: BLENotification):
"""Broadcast notification to all connected devices.
Args:
notification: Notification to broadcast
"""
# Convert notification to JSON
payload = {
"type": notification.type.value,
"message": notification.message,
"data": notification.data,
"timestamp": notification.timestamp
}
payload_bytes = json.dumps(payload).encode('utf-8')
# Use GATT server if available
if self._gatt_adapter and self._gatt_server_running:
try:
# Send via system notification characteristic
from ..ble.characteristics import SystemCharacteristicUUIDs
await self._gatt_adapter.send_notification(
SystemCharacteristicUUIDs.INFO,
payload_bytes
)
logger.debug("BLE notification sent via GATT: %s", notification.type.value)
return
except Exception as e:
logger.warning("Failed to send GATT notification: %s", e)
# Fallback: log the notification
logger.info("BLE Notification: %s - %s", notification.type.value, notification.message)
def is_available(self) -> bool:
"""Check if BLE is available.
Returns:
True if BLE is available, False otherwise
"""
return self._is_available
def is_advertising(self) -> bool:
"""Check if BLE is currently advertising.
Returns:
True if advertising, False otherwise
"""
return self._is_advertising
def get_connected_devices(self) -> List[str]:
"""Get list of connected device addresses.
Returns:
List of connected device MAC addresses
"""
return self._connected_devices.copy()
# Global BLE manager instance
_ble_manager: Optional[BLEManager] = None
def get_ble_manager() -> BLEManager:
"""Get the global BLE manager instance."""
global _ble_manager
if _ble_manager is None:
_ble_manager = BLEManager()
return _ble_manager

View File

@@ -0,0 +1,419 @@
"""Plugin Manager for Dangerous Pi.
Provides a plugin framework for extending functionality.
Supports dynamic loading, enabling/disabling, and lifecycle management.
"""
import asyncio
import importlib.util
import inspect
import json
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
import sys
from .. import config
class PluginStatus(str, Enum):
"""Plugin status enum."""
LOADED = "loaded"
ENABLED = "enabled"
DISABLED = "disabled"
ERROR = "error"
@dataclass
class PluginMetadata:
"""Plugin metadata information."""
id: str
name: str
version: str
description: str
author: str
homepage: Optional[str] = None
dependencies: List[str] = None
permissions: List[str] = None
def __post_init__(self):
if self.dependencies is None:
self.dependencies = []
if self.permissions is None:
self.permissions = []
@dataclass
class PluginInfo:
"""Plugin runtime information."""
metadata: PluginMetadata
status: PluginStatus
path: Path
error_message: Optional[str] = None
enabled: bool = False
class PluginBase:
"""Base class for all plugins.
Plugins should inherit from this class and implement the required methods.
"""
def __init__(self):
"""Initialize the plugin."""
self.metadata: Optional[PluginMetadata] = None
self.hooks: Dict[str, List[Callable]] = {}
async def on_load(self):
"""Called when the plugin is loaded.
Override this to perform initialization tasks.
"""
pass
async def on_enable(self):
"""Called when the plugin is enabled.
Override this to start plugin functionality.
"""
pass
async def on_disable(self):
"""Called when the plugin is disabled.
Override this to stop plugin functionality.
"""
pass
async def on_unload(self):
"""Called when the plugin is unloaded.
Override this to perform cleanup tasks.
"""
pass
def register_hook(self, hook_name: str, callback: Callable):
"""Register a hook callback.
Args:
hook_name: Name of the hook (e.g., "pm3_command", "update_check")
callback: Async callback function
"""
if hook_name not in self.hooks:
self.hooks[hook_name] = []
self.hooks[hook_name].append(callback)
def get_metadata(self) -> PluginMetadata:
"""Get plugin metadata.
Returns:
PluginMetadata object
Raises:
NotImplementedError if not implemented by plugin
"""
raise NotImplementedError("Plugin must implement get_metadata()")
class PluginManager:
"""Manages plugin loading, enabling, and lifecycle."""
def __init__(self):
"""Initialize the plugin manager."""
self._plugins: Dict[str, PluginInfo] = {}
self._plugin_instances: Dict[str, PluginBase] = {}
self._plugin_dir = config.BASE_DIR / "app" / "plugins"
self._hooks: Dict[str, List[Callable]] = {}
self._enabled_plugins: List[str] = []
# Create plugins directory if it doesn't exist
self._plugin_dir.mkdir(parents=True, exist_ok=True)
async def discover_plugins(self) -> List[str]:
"""Discover all available plugins in the plugins directory.
Returns:
List of discovered plugin IDs
"""
discovered = []
# Look for plugin directories
for plugin_path in self._plugin_dir.iterdir():
if not plugin_path.is_dir() or plugin_path.name.startswith("_"):
continue
# Check for plugin.json metadata file
metadata_file = plugin_path / "plugin.json"
if not metadata_file.exists():
continue
try:
# Load metadata
with open(metadata_file, 'r') as f:
metadata_dict = json.load(f)
metadata = PluginMetadata(**metadata_dict)
# Check for main.py
main_file = plugin_path / "main.py"
if not main_file.exists():
print(f"Plugin {metadata.id} missing main.py")
continue
# Create plugin info
plugin_info = PluginInfo(
metadata=metadata,
status=PluginStatus.LOADED,
path=plugin_path,
enabled=False
)
self._plugins[metadata.id] = plugin_info
discovered.append(metadata.id)
print(f"Discovered plugin: {metadata.name} v{metadata.version}")
except Exception as e:
print(f"Error discovering plugin in {plugin_path}: {e}")
continue
return discovered
async def load_plugin(self, plugin_id: str) -> bool:
"""Load a plugin into memory.
Args:
plugin_id: ID of the plugin to load
Returns:
True if loaded successfully, False otherwise
"""
if plugin_id not in self._plugins:
print(f"Plugin {plugin_id} not found")
return False
plugin_info = self._plugins[plugin_id]
try:
# Import the plugin module
main_file = plugin_info.path / "main.py"
spec = importlib.util.spec_from_file_location(
f"plugins.{plugin_id}",
main_file
)
module = importlib.util.module_from_spec(spec)
sys.modules[f"plugins.{plugin_id}"] = module
spec.loader.exec_module(module)
# Find the plugin class (should inherit from PluginBase)
plugin_class = None
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, PluginBase) and obj is not PluginBase:
plugin_class = obj
break
if not plugin_class:
raise ValueError("No plugin class found in main.py")
# Instantiate the plugin
plugin_instance = plugin_class()
plugin_instance.metadata = plugin_info.metadata
# Call on_load lifecycle method
await plugin_instance.on_load()
# Store the instance
self._plugin_instances[plugin_id] = plugin_instance
plugin_info.status = PluginStatus.LOADED
print(f"Loaded plugin: {plugin_info.metadata.name}")
return True
except Exception as e:
print(f"Error loading plugin {plugin_id}: {e}")
plugin_info.status = PluginStatus.ERROR
plugin_info.error_message = str(e)
return False
async def enable_plugin(self, plugin_id: str) -> bool:
"""Enable a loaded plugin.
Args:
plugin_id: ID of the plugin to enable
Returns:
True if enabled successfully, False otherwise
"""
if plugin_id not in self._plugin_instances:
# Try to load it first
if not await self.load_plugin(plugin_id):
return False
plugin_instance = self._plugin_instances[plugin_id]
plugin_info = self._plugins[plugin_id]
try:
# Call on_enable lifecycle method
await plugin_instance.on_enable()
# Register plugin hooks
for hook_name, callbacks in plugin_instance.hooks.items():
if hook_name not in self._hooks:
self._hooks[hook_name] = []
self._hooks[hook_name].extend(callbacks)
plugin_info.status = PluginStatus.ENABLED
plugin_info.enabled = True
if plugin_id not in self._enabled_plugins:
self._enabled_plugins.append(plugin_id)
print(f"Enabled plugin: {plugin_info.metadata.name}")
return True
except Exception as e:
print(f"Error enabling plugin {plugin_id}: {e}")
plugin_info.status = PluginStatus.ERROR
plugin_info.error_message = str(e)
return False
async def disable_plugin(self, plugin_id: str) -> bool:
"""Disable an enabled plugin.
Args:
plugin_id: ID of the plugin to disable
Returns:
True if disabled successfully, False otherwise
"""
if plugin_id not in self._plugin_instances:
return False
plugin_instance = self._plugin_instances[plugin_id]
plugin_info = self._plugins[plugin_id]
try:
# Call on_disable lifecycle method
await plugin_instance.on_disable()
# Unregister plugin hooks
for hook_name, callbacks in plugin_instance.hooks.items():
if hook_name in self._hooks:
for callback in callbacks:
if callback in self._hooks[hook_name]:
self._hooks[hook_name].remove(callback)
plugin_info.status = PluginStatus.DISABLED
plugin_info.enabled = False
if plugin_id in self._enabled_plugins:
self._enabled_plugins.remove(plugin_id)
print(f"Disabled plugin: {plugin_info.metadata.name}")
return True
except Exception as e:
print(f"Error disabling plugin {plugin_id}: {e}")
plugin_info.error_message = str(e)
return False
async def unload_plugin(self, plugin_id: str) -> bool:
"""Unload a plugin from memory.
Args:
plugin_id: ID of the plugin to unload
Returns:
True if unloaded successfully, False otherwise
"""
if plugin_id not in self._plugin_instances:
return False
# Disable first if enabled
if self._plugins[plugin_id].enabled:
await self.disable_plugin(plugin_id)
plugin_instance = self._plugin_instances[plugin_id]
try:
# Call on_unload lifecycle method
await plugin_instance.on_unload()
# Remove from instances
del self._plugin_instances[plugin_id]
# Remove from sys.modules
module_name = f"plugins.{plugin_id}"
if module_name in sys.modules:
del sys.modules[module_name]
print(f"Unloaded plugin: {plugin_id}")
return True
except Exception as e:
print(f"Error unloading plugin {plugin_id}: {e}")
return False
async def trigger_hook(self, hook_name: str, *args, **kwargs) -> List[Any]:
"""Trigger a hook and collect results from all registered callbacks.
Args:
hook_name: Name of the hook to trigger
*args: Positional arguments for hook callbacks
**kwargs: Keyword arguments for hook callbacks
Returns:
List of results from all callbacks
"""
if hook_name not in self._hooks:
return []
results = []
for callback in self._hooks[hook_name]:
try:
if asyncio.iscoroutinefunction(callback):
result = await callback(*args, **kwargs)
else:
result = callback(*args, **kwargs)
results.append(result)
except Exception as e:
print(f"Error in hook {hook_name}: {e}")
return results
def get_plugin_info(self, plugin_id: str) -> Optional[PluginInfo]:
"""Get information about a plugin.
Args:
plugin_id: ID of the plugin
Returns:
PluginInfo object or None if not found
"""
return self._plugins.get(plugin_id)
def get_all_plugins(self) -> Dict[str, PluginInfo]:
"""Get information about all plugins.
Returns:
Dictionary of plugin ID to PluginInfo
"""
return self._plugins.copy()
def get_enabled_plugins(self) -> List[str]:
"""Get list of enabled plugin IDs.
Returns:
List of enabled plugin IDs
"""
return self._enabled_plugins.copy()
# Global plugin manager instance
_plugin_manager: Optional[PluginManager] = None
def get_plugin_manager() -> PluginManager:
"""Get the global plugin manager instance."""
global _plugin_manager
if _plugin_manager is None:
_plugin_manager = PluginManager()
return _plugin_manager

View File

@@ -0,0 +1,574 @@
"""Proxmark3 Device Manager for multi-device support."""
import asyncio
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
import json
try:
import pyudev
UDEV_AVAILABLE = True
except ImportError:
UDEV_AVAILABLE = False
try:
import serial.tools.list_ports
SERIAL_AVAILABLE = True
except ImportError:
SERIAL_AVAILABLE = False
from ..workers.pm3_worker import PM3Worker, SubprocessPM3Worker
from .. import config
# Flag to track if Python bindings work (set once on first try)
_python_bindings_work = None
logger = logging.getLogger(__name__)
class DeviceStatus(Enum):
"""Device availability status."""
CONNECTED = "connected" # Ready to use
DISCONNECTED = "disconnected" # Not detected
IN_USE = "in_use" # Active session
ERROR = "error" # Communication error
VERSION_MISMATCH = "version_mismatch" # Firmware incompatible
FLASHING = "flashing" # Firmware update in progress
BOOTLOADER_MODE = "bootloader_mode" # In bootloader, needs flash
DISABLED = "disabled" # Mismatch ignored by user
@dataclass
class PM3FirmwareInfo:
"""Firmware version information."""
bootrom_version: str = "unknown" # e.g., "v4.14831"
os_version: str = "unknown" # e.g., "v4.14831"
client_version: str = "unknown" # Local client version
compatible: bool = False # Versions match
needs_upgrade: bool = False # Device firmware older
needs_downgrade: bool = False # Device firmware newer
bootloader_outdated: bool = False # Bootloader needs update
@dataclass
class PM3Device:
"""Represents a single Proxmark3 device."""
device_id: str # Unique ID (hash of serial + device path)
device_path: str # /dev/ttyACM0, /dev/ttyACM1, etc.
serial_number: Optional[str] = None # USB serial number (if available)
friendly_name: Optional[str] = None # User-assigned name
usb_vid: Optional[str] = None # USB Vendor ID
usb_pid: Optional[str] = None # USB Product ID
status: DeviceStatus = DeviceStatus.DISCONNECTED
firmware_info: PM3FirmwareInfo = field(default_factory=PM3FirmwareInfo)
last_seen: datetime = field(default_factory=datetime.now)
first_seen: datetime = field(default_factory=datetime.now)
worker: Optional[PM3Worker] = None # Dedicated worker instance
metadata: Dict = field(default_factory=dict) # Additional metadata
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
"device_id": self.device_id,
"device_path": self.device_path,
"serial_number": self.serial_number,
"friendly_name": self.friendly_name or self.device_path.split('/')[-1],
"usb_vid": self.usb_vid,
"usb_pid": self.usb_pid,
"status": self.status.value,
"firmware_info": {
"bootrom_version": self.firmware_info.bootrom_version,
"os_version": self.firmware_info.os_version,
"client_version": self.firmware_info.client_version,
"compatible": self.firmware_info.compatible,
"needs_upgrade": self.firmware_info.needs_upgrade,
"needs_downgrade": self.firmware_info.needs_downgrade,
},
"last_seen": self.last_seen.isoformat(),
"first_seen": self.first_seen.isoformat(),
"connected": self.status == DeviceStatus.CONNECTED,
"in_use": self.status == DeviceStatus.IN_USE,
}
class PM3DeviceManager:
"""Manages multiple Proxmark3 devices."""
# USB VID/PID for Proxmark3 devices
PM3_USB_VID_PRIMARY = "9ac4" # Standard PM3
PM3_USB_PID_PRIMARY = "4b8f"
PM3_USB_VID_EASY = "502d" # PM3 Easy
PM3_USB_PID_EASY = "502d"
# Alternative identifiers
PM3_VENDOR_IDS = ["9ac4", "502d", "2d0d"] # Known PM3 vendor IDs
PM3_PRODUCT_IDS = ["4b8f", "502d"] # Known PM3 product IDs
def __init__(self):
"""Initialize device manager."""
self._devices: Dict[str, PM3Device] = {} # device_id -> PM3Device
self._lock = asyncio.Lock()
self._monitor_task: Optional[asyncio.Task] = None
self._udev_context = None
self._udev_monitor = None
self._device_change_callbacks: List = [] # Callbacks for device changes
# Device discovery settings
self.auto_discover = True
self.discovery_interval = 0.5 # seconds - 500ms for responsive SSE updates
self.device_timeout = 300 # seconds
self._last_device_state: Dict[str, str] = {} # device_id -> status for change detection
logger.info("PM3DeviceManager initialized")
def register_device_change_callback(self, callback):
"""Register a callback for device list changes.
Args:
callback: Async function that takes a list of PM3Device
"""
self._device_change_callbacks.append(callback)
logger.info(f"Registered device change callback: {callback}")
async def _notify_device_change(self, force: bool = False):
"""Notify all registered callbacks of device list changes.
Args:
force: If True, send notification even if no changes detected
"""
# Build current state snapshot
current_state = {
d.device_id: d.status.value if hasattr(d.status, 'value') else str(d.status)
for d in self._devices.values()
}
# Check if anything changed
if not force and current_state == self._last_device_state:
return # No changes, skip notification
# Update last state
self._last_device_state = current_state.copy()
# Notify all callbacks
devices = list(self._devices.values())
for callback in self._device_change_callbacks:
try:
await callback(devices)
except Exception as e:
logger.error(f"Error in device change callback: {e}")
async def start(self):
"""Start device manager and monitoring."""
logger.info("Starting PM3 device manager")
# Initial device discovery (force notification to send initial state)
await self.discover_devices()
await self._notify_device_change(force=True)
# Start monitoring for hotplug events
if UDEV_AVAILABLE:
await self._start_udev_monitor()
else:
logger.warning("pyudev not available, using polling fallback")
await self._start_polling_monitor()
async def stop(self):
"""Stop device manager and monitoring."""
logger.info("Stopping PM3 device manager")
if self._monitor_task:
self._monitor_task.cancel()
try:
await self._monitor_task
except asyncio.CancelledError:
pass
# Disconnect all devices
async with self._lock:
for device in self._devices.values():
if device.worker:
await device.worker.disconnect()
async def discover_devices(self) -> List[PM3Device]:
"""Scan USB ports for PM3 devices.
Returns:
List of discovered PM3Device objects
"""
async with self._lock:
discovered_paths = set()
# Method 1: Use pyserial to enumerate serial ports
if SERIAL_AVAILABLE:
discovered_paths.update(await self._discover_via_serial())
# Method 2: Scan /dev/ttyACM* directly
discovered_paths.update(await self._discover_via_dev())
# Process discovered devices
current_devices = {}
for device_path in discovered_paths:
device_id = self._generate_device_id(device_path)
if device_id in self._devices:
# Update existing device
device = self._devices[device_id]
device.last_seen = datetime.now()
device.status = DeviceStatus.CONNECTED
else:
# Create new device
device = await self._create_device(device_path)
current_devices[device_id] = device
# Mark missing devices as disconnected
for device_id, device in self._devices.items():
if device_id not in current_devices:
device.status = DeviceStatus.DISCONNECTED
current_devices[device_id] = device
self._devices = current_devices
connected_count = len([d for d in self._devices.values() if d.status == DeviceStatus.CONNECTED])
logger.info(f"Discovered {connected_count} connected PM3 devices")
# Notify callbacks of device changes (only if state changed)
await self._notify_device_change()
return list(self._devices.values())
async def _discover_via_serial(self) -> set:
"""Discover devices using pyserial."""
devices = set()
try:
ports = serial.tools.list_ports.comports()
for port in ports:
# Check if it's a PM3 device by VID/PID
if port.vid and port.pid:
vid = f"{port.vid:04x}"
pid = f"{port.pid:04x}"
if vid in self.PM3_VENDOR_IDS or pid in self.PM3_PRODUCT_IDS:
devices.add(port.device)
logger.debug(f"Found PM3 device via serial: {port.device} (VID:{vid} PID:{pid})")
except Exception as e:
logger.error(f"Error discovering devices via serial: {e}")
return devices
async def _discover_via_dev(self) -> set:
"""Discover devices by scanning /dev/ttyACM*."""
devices = set()
try:
# Look for /dev/ttyACM* devices
dev_path = Path("/dev")
for device_file in dev_path.glob("ttyACM*"):
if device_file.is_char_device():
devices.add(str(device_file))
logger.debug(f"Found device: {device_file}")
except Exception as e:
logger.error(f"Error scanning /dev: {e}")
return devices
async def _create_device(self, device_path: str) -> PM3Device:
"""Create a new PM3Device object.
Args:
device_path: Path to device (e.g., /dev/ttyACM0)
Returns:
PM3Device object
"""
device_id = self._generate_device_id(device_path)
# Get USB info if available
usb_info = await self._get_usb_info(device_path)
# Create device object
device = PM3Device(
device_id=device_id,
device_path=device_path,
serial_number=usb_info.get("serial"),
usb_vid=usb_info.get("vid"),
usb_pid=usb_info.get("pid"),
status=DeviceStatus.CONNECTED,
friendly_name=None, # Will be set by user or auto-generated
first_seen=datetime.now(),
last_seen=datetime.now()
)
# Use SubprocessPM3Worker (more reliable than SWIG bindings which may not be built)
device.worker = SubprocessPM3Worker(device_path=device_path)
# Query firmware version (in background, don't block)
asyncio.create_task(self._query_firmware_version(device))
logger.info(f"Created device {device_id} at {device_path}")
return device
def _generate_device_id(self, device_path: str, serial: Optional[str] = None) -> str:
"""Generate unique device ID.
Args:
device_path: Device path
serial: Optional serial number
Returns:
Unique device ID
"""
# Use serial number if available, otherwise use path
identifier = serial if serial else device_path
# Generate short hash
hash_obj = hashlib.md5(identifier.encode())
return f"pm3_{hash_obj.hexdigest()[:8]}"
async def _get_usb_info(self, device_path: str) -> dict:
"""Get USB device information.
Args:
device_path: Device path
Returns:
Dictionary with vid, pid, serial
"""
info = {}
if SERIAL_AVAILABLE:
try:
# Find port info
ports = serial.tools.list_ports.comports()
for port in ports:
if port.device == device_path:
if port.vid:
info["vid"] = f"{port.vid:04x}"
if port.pid:
info["pid"] = f"{port.pid:04x}"
if port.serial_number:
info["serial"] = port.serial_number
break
except Exception as e:
logger.error(f"Error getting USB info: {e}")
return info
async def _query_firmware_version(self, device: PM3Device):
"""Query device firmware version.
Args:
device: PM3Device to query
"""
try:
if not device.worker:
return
# Execute hw version command
result = await device.worker.execute_command("hw version")
if result.success:
# Parse firmware version from output
firmware_info = self._parse_firmware_version(result.output)
device.firmware_info = firmware_info
# Update device status based on compatibility
if not firmware_info.compatible:
device.status = DeviceStatus.VERSION_MISMATCH
logger.info(f"Device {device.device_id} firmware: {firmware_info.os_version}")
except Exception as e:
logger.error(f"Error querying firmware version for {device.device_id}: {e}")
device.status = DeviceStatus.ERROR
def _parse_firmware_version(self, output: str) -> PM3FirmwareInfo:
"""Parse firmware version from hw version output.
Args:
output: Output from 'hw version' command
Returns:
PM3FirmwareInfo object
"""
import re
info = PM3FirmwareInfo()
# Parse bootrom version (handles Iceman format: "Iceman/master/v4.20469-104-ge509967ab-suspect")
bootrom_match = re.search(r'Bootrom[:\s.]+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
if bootrom_match:
info.bootrom_version = bootrom_match.group(1).strip()
# Parse OS version (handles Iceman format)
os_match = re.search(r'OS[:\s.]+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
if os_match:
info.os_version = os_match.group(1).strip()
# Parse client version (handles Iceman format: "Iceman/master/4fa8f27-dirty-suspect")
client_match = re.search(r'\[\s*Client\s*\]\s+(.+?)(?:\s+\d{4}-\d{2}-\d{2}|\s+[0-9a-f]{9}|$)', output, re.IGNORECASE | re.MULTILINE)
if client_match:
info.client_version = client_match.group(1).strip()
# Check compatibility (exact match required per plan)
# For Iceman firmware, we compare the version strings directly
info.compatible = (
info.os_version != "unknown" and
info.bootrom_version != "unknown" and
info.client_version != "unknown" and
info.os_version == info.client_version and
info.bootrom_version == info.client_version
)
# For version comparison, extract just the version number
def extract_version(ver_str: str) -> str:
"""Extract version number from version string."""
ver_match = re.search(r'v?(\d+\.\d+)', ver_str)
return ver_match.group(1) if ver_match else ver_str
os_ver = extract_version(info.os_version)
client_ver = extract_version(info.client_version)
bootrom_ver = extract_version(info.bootrom_version)
info.needs_upgrade = os_ver < client_ver if os_ver != "unknown" and client_ver != "unknown" else False
info.needs_downgrade = os_ver > client_ver if os_ver != "unknown" and client_ver != "unknown" else False
info.bootloader_outdated = bootrom_ver < client_ver if bootrom_ver != "unknown" and client_ver != "unknown" else False
return info
def _get_local_client_version(self) -> str:
"""Get version of locally installed proxmark3 client.
Returns:
Client version string or "unknown"
"""
# TODO: Implement actual version detection
# For now, return a placeholder
return "v4.14831"
async def get_device(self, device_id: str) -> Optional[PM3Device]:
"""Get device by ID.
Args:
device_id: Device ID
Returns:
PM3Device or None if not found
"""
async with self._lock:
return self._devices.get(device_id)
async def get_all_devices(self) -> List[PM3Device]:
"""Get all known devices.
Returns:
List of all PM3Device objects
"""
async with self._lock:
return list(self._devices.values())
async def get_available_devices(self) -> List[PM3Device]:
"""Get devices not currently in use.
Returns:
List of available PM3Device objects
"""
async with self._lock:
return [
device for device in self._devices.values()
if device.status == DeviceStatus.CONNECTED
]
async def get_connected_devices(self) -> List[PM3Device]:
"""Get connected devices.
Returns:
List of connected PM3Device objects
"""
async with self._lock:
return [
device for device in self._devices.values()
if device.status not in [DeviceStatus.DISCONNECTED, DeviceStatus.ERROR]
]
async def set_device_status(self, device_id: str, status: DeviceStatus) -> bool:
"""Set device status.
Args:
device_id: Device ID
status: New status
Returns:
True if successful, False if device not found
"""
async with self._lock:
device = self._devices.get(device_id)
if device:
device.status = status
return True
return False
async def identify_device(self, device_id: str, duration_ms: int = 2000):
"""Blink LEDs on device for identification.
Args:
device_id: Device ID
duration_ms: Blink duration in milliseconds
Raises:
ValueError: If device not found
RuntimeError: If identification fails
"""
device = await self.get_device(device_id)
if not device:
raise ValueError(f"Device {device_id} not found")
if not device.worker:
raise RuntimeError(f"Device {device_id} has no worker")
# Use hw led command to identify the device with visual LED pattern
try:
result = await device.worker.execute_command(
f"hw led --identify --duration {duration_ms}"
)
if not result.success:
raise RuntimeError(f"Failed to identify device: {result.error}")
logger.info(f"Identified device {device_id}")
except Exception as e:
logger.error(f"Error identifying device {device_id}: {e}")
raise RuntimeError(f"Failed to identify device: {e}")
async def _start_udev_monitor(self):
"""Start udev monitoring for device hotplug events."""
try:
logger.info("Starting udev device monitor")
# This will be implemented in a separate task
# For now, fall back to polling
await self._start_polling_monitor()
except Exception as e:
logger.error(f"Error starting udev monitor: {e}")
await self._start_polling_monitor()
async def _start_polling_monitor(self):
"""Start polling-based device monitoring."""
logger.info(f"Starting polling device monitor (interval: {self.discovery_interval}s)")
async def poll_devices():
while True:
try:
await asyncio.sleep(self.discovery_interval)
await self.discover_devices()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in device polling: {e}")
self._monitor_task = asyncio.create_task(poll_devices())

View File

@@ -0,0 +1,228 @@
"""Session manager for per-device access control.
Supports multiple concurrent sessions, one per PM3 device. Each device
can have at most one active session at a time.
"""
import asyncio
import time
from typing import Optional, Dict
from dataclasses import dataclass
import uuid
from .. import config
@dataclass
class Session:
"""Active session information."""
session_id: str
device_id: Optional[str] # None for legacy single-device mode
client_ip: str
user_agent: Optional[str]
created_at: float
last_activity: float
class SessionManager:
"""Manages per-device sessions for PM3 access.
Each PM3 device can have at most one active session. Multiple devices
can be used concurrently by different sessions.
"""
# Key used for legacy single-device mode
DEFAULT_DEVICE_KEY = "_default"
def __init__(self):
"""Initialize session manager."""
self._active_sessions: Dict[str, Session] = {} # keyed by device_id
self._lock = asyncio.Lock()
def _get_device_key(self, device_id: Optional[str]) -> str:
"""Get the key to use for session lookup.
Args:
device_id: Device ID or None for legacy mode
Returns:
Key to use in _active_sessions dict
"""
return device_id or self.DEFAULT_DEVICE_KEY
def _cleanup_expired_sessions(self) -> None:
"""Remove any expired sessions from the active sessions dict."""
current_time = time.time()
expired_keys = [
key for key, session in self._active_sessions.items()
if current_time - session.last_activity > config.SESSION_TIMEOUT
]
for key in expired_keys:
del self._active_sessions[key]
def has_active_session(self, device_id: Optional[str] = None) -> bool:
"""Check if there's an active session for a device.
Args:
device_id: Device ID to check. If None, checks for any active session.
Returns:
True if active session exists
"""
self._cleanup_expired_sessions()
if device_id is None:
# Check if ANY session is active (legacy behavior)
return len(self._active_sessions) > 0
key = self._get_device_key(device_id)
return key in self._active_sessions
async def create_session(
self,
client_ip: str,
user_agent: Optional[str] = None,
force_takeover: bool = False,
device_id: Optional[str] = None
) -> tuple[bool, Optional[str], Optional[str]]:
"""Create a new session for a device.
Args:
client_ip: Client IP address
user_agent: Client user agent string
force_takeover: Force takeover of existing session
device_id: Device ID to create session for (None for legacy mode)
Returns:
Tuple of (success, session_id, error_message)
"""
async with self._lock:
self._cleanup_expired_sessions()
key = self._get_device_key(device_id)
# Check if another session is active for this device
if key in self._active_sessions and not force_takeover:
return False, None, f"Another session is active for device {device_id or 'default'}"
# Create new session
session_id = str(uuid.uuid4())
current_time = time.time()
self._active_sessions[key] = Session(
session_id=session_id,
device_id=device_id,
client_ip=client_ip,
user_agent=user_agent,
created_at=current_time,
last_activity=current_time
)
return True, session_id, None
async def release_session(self, session_id: str, device_id: Optional[str] = None) -> bool:
"""Release a session.
Args:
session_id: Session ID to release
device_id: Device ID (if known). If None, searches all sessions.
Returns:
True if session was released, False if not found
"""
async with self._lock:
if device_id is not None:
# Direct lookup by device_id
key = self._get_device_key(device_id)
if key in self._active_sessions and self._active_sessions[key].session_id == session_id:
del self._active_sessions[key]
return True
else:
# Search all sessions for the session_id
for key, session in list(self._active_sessions.items()):
if session.session_id == session_id:
del self._active_sessions[key]
return True
return False
def update_activity(self, session_id: str, device_id: Optional[str] = None) -> bool:
"""Update session activity timestamp.
Args:
session_id: Session ID to update
device_id: Device ID (if known). If None, searches all sessions.
Returns:
True if updated, False if session not found
"""
if device_id is not None:
key = self._get_device_key(device_id)
if key in self._active_sessions and self._active_sessions[key].session_id == session_id:
self._active_sessions[key].last_activity = time.time()
return True
else:
# Search all sessions
for session in self._active_sessions.values():
if session.session_id == session_id:
session.last_activity = time.time()
return True
return False
def can_execute(self, session_id: Optional[str], device_id: Optional[str] = None) -> bool:
"""Check if a session can execute commands on a device.
Args:
session_id: Session ID to check (None for no session)
device_id: Device ID to check (None for legacy single-device mode)
Returns:
True if session can execute, False otherwise
"""
self._cleanup_expired_sessions()
key = self._get_device_key(device_id)
# No active session for this device - allow execution
if key not in self._active_sessions:
return True
# Check if the provided session ID matches the device's active session
if session_id and self._active_sessions[key].session_id == session_id:
return True
return False
def get_active_session(self, device_id: Optional[str] = None) -> Optional[Session]:
"""Get the currently active session for a device.
Args:
device_id: Device ID to get session for. If None, returns any active session.
Returns:
Active session or None
"""
self._cleanup_expired_sessions()
if device_id is None:
# Return first active session (legacy behavior)
return next(iter(self._active_sessions.values()), None)
key = self._get_device_key(device_id)
return self._active_sessions.get(key)
def get_session_for_device(self, device_id: str) -> Optional[Session]:
"""Get the active session for a specific device.
Args:
device_id: Device ID to get session for
Returns:
Active session for the device or None
"""
return self.get_active_session(device_id)
def get_all_active_sessions(self) -> Dict[str, Session]:
"""Get all active sessions.
Returns:
Dict of device_id -> Session for all active sessions
"""
self._cleanup_expired_sessions()
return dict(self._active_sessions)

View File

@@ -0,0 +1,479 @@
"""Update Manager for Dangerous Pi.
Handles checking for updates from GitHub releases, downloading,
and applying updates to the system.
"""
import asyncio
import hashlib
import json
import os
import re
import shutil
import tempfile
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List
import aiohttp
import aiosqlite
from .. import config
class UpdateStatus(str, Enum):
"""Update status enum."""
IDLE = "idle"
CHECKING = "checking"
AVAILABLE = "available"
DOWNLOADING = "downloading"
INSTALLING = "installing"
COMPLETE = "complete"
FAILED = "failed"
@dataclass
class ReleaseInfo:
"""GitHub release information."""
version: str
tag_name: str
published_at: str
download_url: str
changelog: str
is_prerelease: bool
asset_name: str
asset_size: int
checksum: Optional[str] = None
@dataclass
class UpdateProgress:
"""Update progress information."""
status: UpdateStatus
current_version: str
available_version: Optional[str] = None
download_progress: float = 0.0
error_message: Optional[str] = None
last_check: Optional[str] = None
class UpdateManager:
"""Manages system updates from GitHub releases."""
def __init__(self):
"""Initialize the update manager."""
self._current_version = config.VERSION
self._github_repo = config.GITHUB_REPO
self._github_api_base = "https://api.github.com"
self._status = UpdateStatus.IDLE
self._progress = UpdateProgress(
status=UpdateStatus.IDLE,
current_version=self._current_version
)
self._latest_release: Optional[ReleaseInfo] = None
self._update_lock = asyncio.Lock()
self._download_path: Optional[Path] = None
self._check_interval = config.UPDATE_CHECK_INTERVAL
async def start_periodic_checks(self):
"""Start periodic update checks in background."""
while True:
try:
await self.check_for_updates()
await asyncio.sleep(self._check_interval)
except asyncio.CancelledError:
break
except Exception as e:
print(f"Error in periodic update check: {e}")
await asyncio.sleep(self._check_interval)
async def check_for_updates(self) -> Dict[str, Any]:
"""Check for available updates from GitHub.
Returns:
Dict containing update status and info
"""
async with self._update_lock:
try:
self._status = UpdateStatus.CHECKING
self._progress.status = UpdateStatus.CHECKING
# Fetch latest release from GitHub
release = await self._fetch_latest_release()
if not release:
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
self._progress.last_check = datetime.utcnow().isoformat()
return {
"update_available": False,
"current_version": self._current_version,
"message": "No releases found"
}
# Compare versions
if self._is_newer_version(release.version, self._current_version):
self._latest_release = release
self._status = UpdateStatus.AVAILABLE
self._progress.status = UpdateStatus.AVAILABLE
self._progress.available_version = release.version
return {
"update_available": True,
"current_version": self._current_version,
"latest_version": release.version,
"release_date": release.published_at,
"changelog": release.changelog,
"is_prerelease": release.is_prerelease,
"download_size": release.asset_size
}
else:
self._status = UpdateStatus.IDLE
self._progress.status = UpdateStatus.IDLE
self._progress.last_check = datetime.utcnow().isoformat()
return {
"update_available": False,
"current_version": self._current_version,
"latest_version": release.version,
"message": "System is up to date"
}
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
raise Exception(f"Failed to check for updates: {e}")
async def download_update(self) -> bool:
"""Download the latest update.
Returns:
True if download successful, False otherwise
"""
async with self._update_lock:
if not self._latest_release:
raise ValueError("No update available to download")
try:
self._status = UpdateStatus.DOWNLOADING
self._progress.status = UpdateStatus.DOWNLOADING
self._progress.download_progress = 0.0
# Create temp directory for download
temp_dir = Path(tempfile.mkdtemp(prefix="dangerous-pi-update-"))
self._download_path = temp_dir / self._latest_release.asset_name
# Download the release asset
async with aiohttp.ClientSession() as session:
async with session.get(self._latest_release.download_url) as response:
if response.status != 200:
raise Exception(f"Download failed with status {response.status}")
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
with open(self._download_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
self._progress.download_progress = (downloaded / total_size) * 100
# Verify checksum if available
if self._latest_release.checksum:
if not await self._verify_checksum(self._download_path, self._latest_release.checksum):
raise Exception("Checksum verification failed")
self._progress.download_progress = 100.0
return True
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
# Cleanup on failure
if self._download_path and self._download_path.parent.exists():
shutil.rmtree(self._download_path.parent, ignore_errors=True)
raise Exception(f"Failed to download update: {e}")
async def install_update(self) -> bool:
"""Install the downloaded update.
Returns:
True if installation successful, False otherwise
"""
async with self._update_lock:
if not self._download_path or not self._download_path.exists():
raise ValueError("No update downloaded")
try:
self._status = UpdateStatus.INSTALLING
self._progress.status = UpdateStatus.INSTALLING
# Extract the update archive
install_dir = Path("/opt/dangerous-pi")
backup_dir = Path("/opt/dangerous-pi-backup")
# Create backup of current installation
if install_dir.exists():
if backup_dir.exists():
shutil.rmtree(backup_dir)
shutil.copytree(install_dir, backup_dir)
# Extract update (assuming tar.gz format)
await self._run_command(
f"tar -xzf {self._download_path} -C {install_dir.parent}"
)
# Run post-install script if exists
post_install = install_dir / "scripts" / "post-install.sh"
if post_install.exists():
await self._run_command(f"sudo bash {post_install}")
# Rebuild PM3 client if needed
await self._rebuild_pm3_client()
# Update version in config
await self._update_version_file(self._latest_release.version)
# Cleanup
shutil.rmtree(self._download_path.parent, ignore_errors=True)
self._download_path = None
self._status = UpdateStatus.COMPLETE
self._progress.status = UpdateStatus.COMPLETE
self._current_version = self._latest_release.version
self._progress.current_version = self._latest_release.version
return True
except Exception as e:
self._status = UpdateStatus.FAILED
self._progress.status = UpdateStatus.FAILED
self._progress.error_message = str(e)
# Restore backup on failure
if backup_dir.exists():
if install_dir.exists():
shutil.rmtree(install_dir)
shutil.copytree(backup_dir, install_dir)
raise Exception(f"Failed to install update: {e}")
async def get_progress(self) -> UpdateProgress:
"""Get current update progress.
Returns:
UpdateProgress object
"""
return self._progress
async def get_release_notes(self, version: Optional[str] = None) -> str:
"""Get release notes for a specific version.
Args:
version: Version to get notes for (latest if None)
Returns:
Release notes as markdown string
"""
try:
if version:
release = await self._fetch_release_by_version(version)
else:
release = await self._fetch_latest_release()
return release.changelog if release else "No release notes available"
except Exception as e:
raise Exception(f"Failed to get release notes: {e}")
async def _fetch_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch latest release from GitHub API.
Returns:
ReleaseInfo object or None
"""
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/latest"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 404:
return None
if response.status != 200:
raise Exception(f"GitHub API returned status {response.status}")
data = await response.json()
return self._parse_release_data(data)
async def _fetch_release_by_version(self, version: str) -> Optional[ReleaseInfo]:
"""Fetch specific release by version tag.
Args:
version: Version tag (e.g., "v1.0.0")
Returns:
ReleaseInfo object or None
"""
tag = version if version.startswith('v') else f'v{version}'
url = f"{self._github_api_base}/repos/{self._github_repo}/releases/tags/{tag}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 404:
return None
if response.status != 200:
raise Exception(f"GitHub API returned status {response.status}")
data = await response.json()
return self._parse_release_data(data)
def _parse_release_data(self, data: Dict[str, Any]) -> ReleaseInfo:
"""Parse GitHub release API response.
Args:
data: GitHub API release data
Returns:
ReleaseInfo object
"""
# Find the main release asset (tar.gz)
assets = data.get('assets', [])
main_asset = None
checksum_asset = None
for asset in assets:
name = asset['name']
if name.endswith('.tar.gz'):
main_asset = asset
elif name.endswith('.sha256'):
checksum_asset = asset
if not main_asset:
raise ValueError("No suitable release asset found")
# Get version from tag (remove 'v' prefix)
version = data['tag_name'].lstrip('v')
# Get checksum if available
checksum = None
if checksum_asset:
# Would need to download and read checksum file
# For now, we'll skip this step
pass
return ReleaseInfo(
version=version,
tag_name=data['tag_name'],
published_at=data['published_at'],
download_url=main_asset['browser_download_url'],
changelog=data.get('body', ''),
is_prerelease=data.get('prerelease', False),
asset_name=main_asset['name'],
asset_size=main_asset['size'],
checksum=checksum
)
def _is_newer_version(self, version1: str, version2: str) -> bool:
"""Compare two semantic versions.
Args:
version1: First version (e.g., "1.2.3")
version2: Second version (e.g., "1.1.0")
Returns:
True if version1 is newer than version2
"""
def parse_version(v: str) -> tuple:
# Remove 'v' prefix and split
v = v.lstrip('v')
parts = re.split(r'[-+]', v)[0] # Remove pre-release/build metadata
return tuple(map(int, parts.split('.')))
try:
v1_parts = parse_version(version1)
v2_parts = parse_version(version2)
return v1_parts > v2_parts
except (ValueError, AttributeError):
return False
async def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool:
"""Verify file checksum.
Args:
file_path: Path to file to verify
expected_checksum: Expected SHA256 checksum
Returns:
True if checksum matches, False otherwise
"""
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while True:
data = f.read(65536) # 64KB chunks
if not data:
break
sha256.update(data)
actual_checksum = sha256.hexdigest()
return actual_checksum.lower() == expected_checksum.lower()
async def _rebuild_pm3_client(self):
"""Rebuild Proxmark3 client after update."""
pm3_dir = Path("/opt/proxmark3")
if not pm3_dir.exists():
print("Proxmark3 directory not found, skipping rebuild")
return
# Run make clean and make
await self._run_command(f"cd {pm3_dir} && make clean && make")
async def _update_version_file(self, version: str):
"""Update version file with new version.
Args:
version: New version string
"""
version_file = Path("/opt/dangerous-pi/VERSION")
version_file.write_text(version)
async def _run_command(self, command: str) -> str:
"""Run a shell command.
Args:
command: Command to run
Returns:
Command output
"""
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise Exception(f"Command failed: {stderr.decode()}")
return stdout.decode()
# Global update manager instance
_update_manager: Optional[UpdateManager] = None
def get_update_manager() -> UpdateManager:
"""Get the global update manager instance."""
global _update_manager
if _update_manager is None:
_update_manager = UpdateManager()
return _update_manager

View File

@@ -0,0 +1,75 @@
"""UPS driver implementations for different hardware.
Supports multiple UPS types:
- pisugar: PiSugar 2/3 via native I2C (no daemon needed)
- pisugar-daemon: PiSugar via pisugar-server TCP daemon (legacy)
- i2c: Generic I2C fuel gauge (MAX17040/MAX17048)
- auto: Automatically detect available UPS hardware
"""
from typing import Optional, Tuple
from .base import UPSDriver, UPSData
from .pisugar_driver import PiSugarDriver # TCP daemon driver (legacy)
from .pisugar_i2c_driver import PiSugarI2CDriver, PiSugarModel, ButtonEvent
from .i2c_driver import I2CFuelGaugeDriver
async def auto_detect_driver(
pisugar_host: str = "127.0.0.1",
pisugar_port: int = 8423,
prefer_native_i2c: bool = True
) -> Tuple[Optional[UPSDriver], str]:
"""Auto-detect available UPS hardware and return appropriate driver.
Detection order (first match wins):
1. PiSugar via native I2C (preferred - no daemon overhead)
2. PiSugar via TCP daemon (fallback if I2C fails)
3. I2C Fuel Gauge (MAX17040/MAX17048 at 0x36 or other addresses)
Args:
pisugar_host: PiSugar server host for daemon detection (legacy)
pisugar_port: PiSugar server port (legacy)
prefer_native_i2c: If True, prefer native I2C driver over daemon
Returns:
Tuple of (driver_instance or None, detection_message)
"""
# Try native PiSugar I2C first (no daemon overhead, minimal CPU)
if prefer_native_i2c:
detected, driver = await PiSugarI2CDriver.detect()
if detected and driver:
model = driver.get_model_name()
return (driver, f"Detected PiSugar UPS via I2C: {model}")
# Try PiSugar daemon (legacy fallback)
detected, model = await PiSugarDriver.detect(host=pisugar_host, port=pisugar_port)
if detected:
driver = PiSugarDriver(host=pisugar_host, port=pisugar_port)
return (driver, f"Detected PiSugar UPS via daemon: {model}")
# Try native I2C again if we skipped it earlier
if not prefer_native_i2c:
detected, driver = await PiSugarI2CDriver.detect()
if detected and driver:
model = driver.get_model_name()
return (driver, f"Detected PiSugar UPS via I2C: {model}")
# Try generic I2C fuel gauge (exclude PiSugar I2C addresses)
exclude_addrs = list(PiSugarI2CDriver.KNOWN_ADDRESSES.keys()) + [PiSugarI2CDriver.RTC_ADDRESS]
detected, i2c_addr = await I2CFuelGaugeDriver.detect(exclude_addresses=exclude_addrs)
if detected:
driver = I2CFuelGaugeDriver(i2c_address=i2c_addr)
return (driver, f"Detected I2C fuel gauge at address 0x{i2c_addr:02X}")
return (None, "No UPS hardware detected")
__all__ = [
"UPSDriver",
"UPSData",
"PiSugarDriver",
"PiSugarI2CDriver",
"PiSugarModel",
"ButtonEvent",
"I2CFuelGaugeDriver",
"auto_detect_driver",
]

View File

@@ -0,0 +1,60 @@
"""Base class for UPS drivers."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
@dataclass
class UPSData:
"""Battery data from UPS hardware."""
percentage: float # 0-100%
voltage: float # mV
current: float # A (positive=charging, negative=discharging)
is_charging: bool
temperature: Optional[float] = None # Celsius
time_remaining: Optional[int] = None # Minutes
class UPSDriver(ABC):
"""Abstract base class for UPS hardware drivers."""
@abstractmethod
async def initialize(self) -> bool:
"""Initialize connection to UPS hardware.
Returns:
True if initialization successful, False otherwise
"""
pass
@abstractmethod
async def read_data(self) -> UPSData:
"""Read current battery data from UPS.
Returns:
UPSData object with current readings
"""
pass
@abstractmethod
async def is_available(self) -> bool:
"""Check if UPS hardware is available.
Returns:
True if hardware is accessible, False otherwise
"""
pass
@abstractmethod
def close(self):
"""Close connection to UPS hardware."""
pass
@abstractmethod
def get_model_name(self) -> str:
"""Get the UPS model/type name.
Returns:
Human-readable model name
"""
pass

View File

@@ -0,0 +1,210 @@
"""I2C Fuel Gauge driver for generic UPS HATs.
Supports MAX17040/MAX17048 and similar I2C fuel gauge chips.
"""
import asyncio
from typing import Optional, Tuple, List
try:
from smbus2 import SMBus
except ImportError:
SMBus = None
from .base import UPSDriver, UPSData
class I2CFuelGaugeDriver(UPSDriver):
"""Driver for I2C-based fuel gauge UPS HATs."""
# Known I2C addresses for fuel gauge chips
# Excludes PiSugar addresses (0x57, 0x32) which are handled by PiSugarDriver
FUEL_GAUGE_ADDRESSES = [
0x36, # MAX17040/MAX17048/MAX17050 (most common)
0x55, # BQ27441 (TI fuel gauge)
0x62, # BQ27510 (TI fuel gauge)
0x0B, # Some SBS battery monitors
]
@classmethod
async def detect(cls, i2c_bus: int = 1, exclude_addresses: List[int] = None) -> Tuple[bool, Optional[int]]:
"""Detect if an I2C fuel gauge is available.
Scans known fuel gauge I2C addresses to find hardware.
Args:
i2c_bus: I2C bus number (default: 1)
exclude_addresses: List of addresses to skip (e.g., PiSugar addresses)
Returns:
Tuple of (detected: bool, i2c_address: Optional[int])
"""
if SMBus is None:
return (False, None)
exclude = exclude_addresses or []
try:
bus = SMBus(i2c_bus)
try:
for addr in cls.FUEL_GAUGE_ADDRESSES:
if addr in exclude:
continue
try:
# Try to read a byte - if device exists, this will succeed
bus.read_byte(addr)
# Additional validation: try to read SOC register (0x04)
# MAX17040/MAX17048 should respond to this
try:
bus.read_i2c_block_data(addr, 0x04, 2)
return (True, addr)
except OSError:
# Device responded to address but not to register read
# Still likely a fuel gauge, just different protocol
return (True, addr)
except OSError:
continue
finally:
bus.close()
except Exception:
pass
return (False, None)
def __init__(self, i2c_address: int = 0x36, i2c_bus: int = 1):
"""Initialize I2C fuel gauge driver.
Args:
i2c_address: I2C address of fuel gauge chip (default: 0x36)
i2c_bus: I2C bus number (default: 1 for Raspberry Pi)
"""
self._i2c_address = i2c_address
self._i2c_bus = i2c_bus
self._bus: Optional[SMBus] = None
self._available = False
self._critical_threshold = 15.0 # For status determination
async def initialize(self) -> bool:
"""Initialize I2C connection to fuel gauge.
Returns:
True if initialization successful, False otherwise
"""
if SMBus is None:
print("smbus2 library not available")
self._available = False
return False
try:
# Open I2C bus
self._bus = SMBus(self._i2c_bus)
# Try to read from device to verify it exists
await self._test_read()
self._available = True
return True
except Exception as e:
print(f"Failed to initialize I2C fuel gauge: {e}")
self._available = False
self._bus = None
return False
async def read_data(self) -> UPSData:
"""Read current battery data from fuel gauge.
Returns:
UPSData object with current readings
"""
if not self._bus or not self._available:
raise RuntimeError("I2C fuel gauge not initialized")
loop = asyncio.get_event_loop()
# Read voltage (registers 0x02-0x03)
voltage_bytes = await loop.run_in_executor(
None,
self._bus.read_i2c_block_data,
self._i2c_address,
0x02,
2
)
voltage_raw = (voltage_bytes[0] << 8) | voltage_bytes[1]
voltage = (voltage_raw >> 4) * 1.25 # Convert to mV
# Read state of charge (registers 0x04-0x05)
soc_bytes = await loop.run_in_executor(
None,
self._bus.read_i2c_block_data,
self._i2c_address,
0x04,
2
)
soc_raw = (soc_bytes[0] << 8) | soc_bytes[1]
percentage = soc_raw / 256.0
# Estimate current (simple approach, some HATs don't provide this)
current = 0.0
# Determine if charging based on voltage
# Typically voltage > 4.1V means charging
is_charging = voltage > 4100 # 4.1V in mV
return UPSData(
percentage=percentage,
voltage=voltage,
current=current,
is_charging=is_charging,
temperature=None, # Not all fuel gauges provide temperature
time_remaining=None # Would need battery capacity config to calculate
)
async def is_available(self) -> bool:
"""Check if I2C fuel gauge is available.
Returns:
True if hardware is accessible, False otherwise
"""
if not self._available or not self._bus:
# Try to reinitialize
return await self.initialize()
return True
def close(self):
"""Close I2C bus connection."""
if self._bus:
self._bus.close()
self._bus = None
self._available = False
def get_model_name(self) -> str:
"""Get the fuel gauge model name.
Returns:
Human-readable model name
"""
return "I2C Fuel Gauge (MAX17040/MAX17048)"
async def _test_read(self):
"""Test read from device to verify it exists.
Raises:
Exception if device doesn't respond
"""
if not self._bus:
raise RuntimeError("I2C bus not initialized")
loop = asyncio.get_event_loop()
# Try to read version register (0x08)
await loop.run_in_executor(
None,
self._bus.read_i2c_block_data,
self._i2c_address,
0x08,
2
)

View File

@@ -0,0 +1,282 @@
"""PiSugar UPS driver.
Communicates with pisugar-server daemon via TCP socket.
Supports PiSugar 2, PiSugar 2 Pro, PiSugar 3, and PiSugar 3 Plus.
"""
import asyncio
import socket
from typing import Optional, Tuple
from .base import UPSDriver, UPSData
class PiSugarDriver(UPSDriver):
"""Driver for PiSugar UPS devices."""
# Known I2C addresses for PiSugar devices
PISUGAR_I2C_ADDRESSES = [0x57, 0x32] # Battery IC, RTC
@classmethod
async def detect(cls, host: str = "127.0.0.1", port: int = 8423) -> Tuple[bool, Optional[str]]:
"""Detect if PiSugar hardware is available.
Tries multiple detection methods:
1. Check if pisugar-server daemon is responding
2. Scan I2C bus for known PiSugar addresses (fallback)
Args:
host: PiSugar server host
port: PiSugar server port
Returns:
Tuple of (detected: bool, model_name: Optional[str])
"""
# Method 1: Try to connect to pisugar-server daemon
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=2.0
)
try:
# Send model query
writer.write(b"get model\n")
await writer.drain()
response = await asyncio.wait_for(
reader.readline(),
timeout=2.0
)
model = response.decode().strip()
if model and "model:" in model.lower():
# Parse "model: PiSugar 3" format
parts = model.split(":", 1)
if len(parts) > 1:
model_name = parts[1].strip()
if model_name and model_name.lower() != "unknown":
return (True, model_name)
finally:
writer.close()
await writer.wait_closed()
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
pass
# Method 2: Check I2C addresses (requires smbus2)
# NOTE: We only log if hardware is found - PiSugarDriver requires the daemon
# If daemon isn't running, we can't use PiSugarDriver even if hardware exists
i2c_found = False
try:
from smbus2 import SMBus
bus = SMBus(1)
try:
for addr in cls.PISUGAR_I2C_ADDRESSES:
try:
bus.read_byte(addr)
i2c_found = True
break
except OSError:
continue
finally:
bus.close()
except ImportError:
pass
except Exception:
pass
if i2c_found:
# Hardware found but daemon not running - can't use PiSugarDriver
print("PiSugar hardware detected via I2C but pisugar-server daemon not running")
print("Install pisugar-server or start the daemon to enable PiSugar UPS support")
return (False, None)
def __init__(self, host: str = "127.0.0.1", port: int = 8423, timeout: float = 5.0):
"""Initialize PiSugar driver.
Args:
host: PiSugar server host (default: localhost)
port: PiSugar server port (default: 8423)
timeout: Command timeout in seconds (default: 5.0)
"""
self._host = host
self._port = port
self._timeout = timeout
self._model: Optional[str] = None
self._available = False
async def initialize(self) -> bool:
"""Initialize connection to PiSugar daemon.
Returns:
True if initialization successful, False otherwise
"""
try:
# Test connection by getting model
self._model = await self._send_command("get model")
if self._model and self._model != "Unknown":
self._available = True
return True
else:
self._available = False
return False
except Exception as e:
print(f"Failed to initialize PiSugar: {e}")
self._available = False
return False
async def read_data(self) -> UPSData:
"""Read current battery data from PiSugar.
Returns:
UPSData object with current readings
"""
if not self._available:
raise RuntimeError("PiSugar not initialized or not available")
# Execute commands in parallel for efficiency
results = await asyncio.gather(
self._send_command("get battery"),
self._send_command("get battery_v"),
self._send_command("get battery_i"),
self._send_command("get battery_power_plugged"),
return_exceptions=True
)
# Parse results
percentage = self._parse_float(results[0], 0.0)
voltage = self._parse_float(results[1], 0.0)
current = self._parse_float(results[2], 0.0)
is_charging = self._parse_bool(results[3], False)
return UPSData(
percentage=percentage,
voltage=voltage,
current=current,
is_charging=is_charging,
temperature=None, # PiSugar doesn't provide temperature
time_remaining=None # Would need battery capacity config to calculate
)
async def is_available(self) -> bool:
"""Check if PiSugar hardware is available.
Returns:
True if hardware is accessible, False otherwise
"""
if not self._available:
# Try to reinitialize
return await self.initialize()
return True
def close(self):
"""Close connection to PiSugar (stateless, nothing to close)."""
self._available = False
def get_model_name(self) -> str:
"""Get the PiSugar model name.
Returns:
Human-readable model name
"""
return self._model or "PiSugar"
async def _send_command(self, command: str) -> str:
"""Send command to PiSugar server and get response.
Args:
command: Command to send (e.g., "get battery")
Returns:
Response string from server
Raises:
RuntimeError: If connection fails or times out
"""
try:
# Connect to PiSugar server
reader, writer = await asyncio.wait_for(
asyncio.open_connection(self._host, self._port),
timeout=self._timeout
)
try:
# Send command
writer.write(f"{command}\n".encode())
await writer.drain()
# Read response (single line)
response = await asyncio.wait_for(
reader.readline(),
timeout=self._timeout
)
return response.decode().strip()
finally:
writer.close()
await writer.wait_closed()
except asyncio.TimeoutError:
raise RuntimeError(f"PiSugar command timeout: {command}")
except ConnectionRefusedError:
raise RuntimeError("PiSugar server not running (connection refused)")
except Exception as e:
raise RuntimeError(f"PiSugar communication error: {e}")
def _parse_float(self, value, default: float = 0.0) -> float:
"""Parse float value from response.
Args:
value: Response value (could be string, float, or Exception)
default: Default value if parsing fails
Returns:
Parsed float value or default
"""
if isinstance(value, Exception):
return default
try:
# PiSugar responses are often in format "battery: 85.5"
if isinstance(value, str):
# Try to extract number from response
parts = value.split(":")
if len(parts) > 1:
return float(parts[1].strip())
else:
return float(value.strip())
return float(value)
except (ValueError, AttributeError):
return default
def _parse_bool(self, value, default: bool = False) -> bool:
"""Parse boolean value from response.
Args:
value: Response value (could be string, bool, or Exception)
default: Default value if parsing fails
Returns:
Parsed boolean value or default
"""
if isinstance(value, Exception):
return default
try:
if isinstance(value, bool):
return value
if isinstance(value, str):
# PiSugar returns "battery_power_plugged: true" or "false"
parts = value.split(":")
if len(parts) > 1:
return parts[1].strip().lower() == "true"
else:
return value.strip().lower() in ("true", "1", "yes")
return bool(value)
except (ValueError, AttributeError):
return default

View File

@@ -0,0 +1,648 @@
"""Native PiSugar I2C driver - direct hardware communication.
Bypasses pisugar-server daemon entirely for minimal CPU usage.
Supports:
- PiSugar 2 (4-LEDs) - IP5209 chip at I2C address 0x75
- PiSugar 2 Pro - IP5209 chip at I2C address 0x75
- PiSugar 3 / 3 Plus - IP5312 chip at I2C address 0x57
Features:
- Battery voltage, current, and percentage reading
- Power plug/charging detection
- Button tap detection (single, double, long press)
- RTC alarm for scheduled wake-up
- Force shutdown capability
Register information extracted from:
https://github.com/PiSugar/pisugar-power-manager-rs
"""
import asyncio
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Callable, List, Optional, Tuple
try:
from smbus2 import SMBus
except ImportError:
SMBus = None
from .base import UPSDriver, UPSData
class PiSugarModel(Enum):
"""Supported PiSugar models."""
PISUGAR_2 = "PiSugar 2"
PISUGAR_2_PRO = "PiSugar 2 Pro"
PISUGAR_3 = "PiSugar 3"
UNKNOWN = "Unknown PiSugar"
class ButtonEvent(Enum):
"""Button event types."""
SINGLE_TAP = "single"
DOUBLE_TAP = "double"
LONG_PRESS = "long"
@dataclass
class ChipConfig:
"""Configuration for a specific battery management chip."""
i2c_address: int
voltage_reg_low: int
voltage_reg_high: int
current_reg_low: int
current_reg_high: int
power_status_reg: int
power_plugged_mask: int
model: PiSugarModel
# IP5209 chip used in PiSugar 2 series
IP5209_CONFIG = ChipConfig(
i2c_address=0x75,
voltage_reg_low=0xA2,
voltage_reg_high=0xA3,
current_reg_low=0xA4,
current_reg_high=0xA5,
power_status_reg=0x55,
power_plugged_mask=0x10, # Bit 4
model=PiSugarModel.PISUGAR_2,
)
# IP5312 chip used in PiSugar 3 series
IP5312_CONFIG = ChipConfig(
i2c_address=0x57,
voltage_reg_low=0xD0,
voltage_reg_high=0xD1,
current_reg_low=0xD2,
current_reg_high=0xD3,
power_status_reg=0xDD,
power_plugged_mask=0x1F, # Value 0x1F = plugged in
model=PiSugarModel.PISUGAR_3,
)
# Battery voltage to percentage lookup curve (voltage in mV -> percentage)
# Based on typical LiPo discharge curve
BATTERY_CURVE = [
(4160, 100),
(4050, 90),
(3920, 80),
(3800, 70),
(3720, 60),
(3650, 50),
(3580, 40),
(3520, 30),
(3420, 20),
(3300, 10),
(3100, 0),
]
class PiSugarI2CDriver(UPSDriver):
"""Native I2C driver for PiSugar UPS devices.
Reads directly from the IP5209/IP5312 battery management chip,
eliminating the need for the pisugar-server daemon.
"""
# Known I2C addresses for auto-detection
KNOWN_ADDRESSES = {
0x75: IP5209_CONFIG, # PiSugar 2 series
0x57: IP5312_CONFIG, # PiSugar 3 series
}
# RTC address (SD3078) - used for detection but not read
RTC_ADDRESS = 0x32
@classmethod
async def detect(cls, i2c_bus: int = 1) -> Tuple[bool, Optional["PiSugarI2CDriver"]]:
"""Detect PiSugar hardware by probing I2C addresses.
Args:
i2c_bus: I2C bus number (default: 1 for Raspberry Pi)
Returns:
Tuple of (detected: bool, driver_instance or None)
"""
if SMBus is None:
return (False, None)
try:
bus = SMBus(i2c_bus)
try:
# Try each known address
for addr, config in cls.KNOWN_ADDRESSES.items():
try:
# Try to read a byte from the address
bus.read_byte(addr)
# Validate by reading voltage registers
try:
bus.read_byte_data(addr, config.voltage_reg_low)
bus.read_byte_data(addr, config.voltage_reg_high)
# Success - create driver instance
driver = cls(chip_config=config, i2c_bus=i2c_bus)
return (True, driver)
except OSError:
# Address responded but not the expected chip
continue
except OSError:
continue
finally:
bus.close()
except Exception:
pass
return (False, None)
def __init__(
self,
chip_config: ChipConfig = IP5209_CONFIG,
i2c_bus: int = 1
):
"""Initialize PiSugar I2C driver.
Args:
chip_config: Configuration for the specific chip
i2c_bus: I2C bus number (default: 1)
"""
self._config = chip_config
self._i2c_bus = i2c_bus
self._bus: Optional[SMBus] = None
self._available = False
async def initialize(self) -> bool:
"""Initialize I2C connection.
Returns:
True if initialization successful
"""
if SMBus is None:
print("smbus2 library not available for PiSugar I2C driver")
return False
try:
self._bus = SMBus(self._i2c_bus)
# Verify we can read from the chip
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
self._config.voltage_reg_low
)
self._available = True
return True
except Exception as e:
print(f"Failed to initialize PiSugar I2C: {e}")
self._available = False
if self._bus:
self._bus.close()
self._bus = None
return False
async def read_data(self) -> UPSData:
"""Read battery data directly from I2C.
Returns:
UPSData with current readings
"""
if not self._bus or not self._available:
raise RuntimeError("PiSugar I2C not initialized")
loop = asyncio.get_event_loop()
# Read voltage
voltage = await self._read_voltage(loop)
# Read current
current = await self._read_current(loop)
# Read power status
is_charging = await self._read_power_status(loop)
# Convert voltage to percentage using lookup curve
percentage = self._voltage_to_percentage(voltage)
return UPSData(
percentage=percentage,
voltage=voltage,
current=current,
is_charging=is_charging,
temperature=None,
time_remaining=None
)
async def _read_voltage(self, loop) -> float:
"""Read battery voltage in mV."""
low = await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
self._config.voltage_reg_low
)
high = await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
self._config.voltage_reg_high
)
if self._config.i2c_address == 0x75: # IP5209
# Two's complement with sign bit at 0x20
raw = ((high & 0x1F) << 8) | low
if high & 0x20:
voltage = 2600.0 - (raw * 0.26855)
else:
voltage = 2600.0 + (raw * 0.26855)
else: # IP5312
raw = ((high & 0x3F) << 8) | low
voltage = (raw * 0.26855) + 2600.0
return voltage
async def _read_current(self, loop) -> float:
"""Read battery current in Amps.
Returns:
Current in Amps (positive = charging, negative = discharging)
"""
low = await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
self._config.current_reg_low
)
high = await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
self._config.current_reg_high
)
if self._config.i2c_address == 0x75: # IP5209
if high & 0x20:
# Sign bit set = discharging = negative current
# Sign extend for proper 2's complement
raw_signed = (((high | 0xC0) << 8) | low)
# Convert to signed 16-bit
if raw_signed > 32767:
raw_signed -= 65536
current = raw_signed * 0.745985 / 1000.0 # Result in A
else:
# Sign bit clear = charging = positive current
raw = ((high & 0x1F) << 8) | low
current = raw * 0.745985 / 1000.0 # Result in A
else: # IP5312
raw = ((high & 0x1F) << 8) | low
current = raw * 2.68554 / 1000.0 # Result in A
if high & 0x20:
current = -current # Sign bit = discharging
return current
async def _read_power_status(self, loop) -> bool:
"""Read power plugged/charging status."""
status = await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
self._config.power_status_reg
)
if self._config.i2c_address == 0x75: # IP5209
return bool(status & self._config.power_plugged_mask)
else: # IP5312
# For IP5312, 0x1F means plugged in
return status == self._config.power_plugged_mask
def _voltage_to_percentage(self, voltage_mv: float) -> float:
"""Convert voltage to battery percentage using lookup curve."""
if voltage_mv >= BATTERY_CURVE[0][0]:
return 100.0
if voltage_mv <= BATTERY_CURVE[-1][0]:
return 0.0
# Linear interpolation between curve points
for i in range(len(BATTERY_CURVE) - 1):
v_high, p_high = BATTERY_CURVE[i]
v_low, p_low = BATTERY_CURVE[i + 1]
if v_low <= voltage_mv <= v_high:
# Interpolate
ratio = (voltage_mv - v_low) / (v_high - v_low)
return p_low + ratio * (p_high - p_low)
return 50.0 # Fallback
async def is_available(self) -> bool:
"""Check if hardware is available."""
if not self._available:
return await self.initialize()
return True
def close(self):
"""Close I2C bus."""
if self._bus:
self._bus.close()
self._bus = None
self._available = False
def get_model_name(self) -> str:
"""Get detected model name."""
return self._config.model.value
# ========== Button Detection ==========
async def read_button_state(self) -> bool:
"""Read current button GPIO state.
Returns:
True if button is pressed
"""
if not self._bus or not self._available:
return False
loop = asyncio.get_event_loop()
try:
status = await loop.run_in_executor(
None,
self._bus.read_byte_data,
self._config.i2c_address,
0x55 # GPIO status register
)
if self._config.i2c_address == 0x75: # IP5209
# 4-LED models use GPIO4 (bit 4), 2-LED use GPIO1 (bit 1)
# Default to 4-LED behavior
return bool(status & 0x10)
else: # IP5312
return bool(status & 0x10)
except Exception:
return False
# ========== RTC Functions (SD3078 at 0x32) ==========
async def get_rtc_time(self) -> Optional[datetime]:
"""Read current time from RTC.
Returns:
datetime object or None if RTC not available
"""
if not self._bus:
return None
loop = asyncio.get_event_loop()
try:
# Read 7 bytes starting from register 0x00
data = await loop.run_in_executor(
None,
self._bus.read_i2c_block_data,
self.RTC_ADDRESS,
0x00,
7
)
# Parse BCD format: sec, min, hour, weekday, day, month, year
second = self._bcd_to_int(data[0] & 0x7F)
minute = self._bcd_to_int(data[1] & 0x7F)
hour = self._bcd_to_int(data[2] & 0x3F) # 24-hour format
day = self._bcd_to_int(data[4] & 0x3F)
month = self._bcd_to_int(data[5] & 0x1F)
year = 2000 + self._bcd_to_int(data[6])
return datetime(year, month, day, hour, minute, second)
except Exception:
return None
async def set_rtc_time(self, dt: datetime) -> bool:
"""Set RTC time.
Args:
dt: datetime to set
Returns:
True if successful
"""
if not self._bus:
return False
loop = asyncio.get_event_loop()
try:
# Enable write mode (CTR2 register 0x10, set WRTC1/WRTC2/WRTC3)
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x10,
0x80 # Enable write
)
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x0F,
0x84 # Enable write
)
# Write time data in BCD format
data = [
self._int_to_bcd(dt.second),
self._int_to_bcd(dt.minute),
self._int_to_bcd(dt.hour) | 0x80, # 24-hour mode
self._int_to_bcd(dt.weekday()),
self._int_to_bcd(dt.day),
self._int_to_bcd(dt.month),
self._int_to_bcd(dt.year % 100)
]
await loop.run_in_executor(
None,
self._bus.write_i2c_block_data,
self.RTC_ADDRESS,
0x00,
data
)
# Disable write mode
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x0F,
0x00
)
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x10,
0x00
)
return True
except Exception:
return False
async def set_wake_alarm(self, wake_time: datetime, repeat_weekdays: int = 0) -> bool:
"""Set RTC alarm for scheduled wake-up.
Args:
wake_time: Time to wake up
repeat_weekdays: Bitmask for weekday repeat (0=one-time, 0x7F=daily)
Returns:
True if successful
"""
if not self._bus:
return False
loop = asyncio.get_event_loop()
try:
# Enable write mode
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x10,
0x80
)
# Write alarm time (registers 0x07-0x0D)
alarm_data = [
self._int_to_bcd(wake_time.second),
self._int_to_bcd(wake_time.minute),
self._int_to_bcd(wake_time.hour) | 0x80, # 24-hour mode
repeat_weekdays, # Weekday repeat mask
self._int_to_bcd(wake_time.day),
self._int_to_bcd(wake_time.month),
self._int_to_bcd(wake_time.year % 100)
]
await loop.run_in_executor(
None,
self._bus.write_i2c_block_data,
self.RTC_ADDRESS,
0x07,
alarm_data
)
# Enable alarm (CTR2 register 0x10, set INTAE bit)
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x0E,
0x07 # Enable hour/minute/second alarm match
)
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x10,
0x04 # Enable alarm interrupt
)
# Set frequency for auto power-on
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x11,
0x01 # 1/2Hz for auto power-on
)
return True
except Exception:
return False
async def clear_wake_alarm(self) -> bool:
"""Clear/disable wake alarm.
Returns:
True if successful
"""
if not self._bus:
return False
loop = asyncio.get_event_loop()
try:
# Disable alarm interrupt
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x10,
0x00
)
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self.RTC_ADDRESS,
0x0E,
0x00
)
return True
except Exception:
return False
# ========== Power Control ==========
async def force_shutdown(self) -> bool:
"""Force immediate shutdown of the Pi.
This enables light-load auto-shutdown on the IP5209/IP5312
which will cut power when the Pi draws minimal current.
Returns:
True if command was sent
"""
if not self._bus or not self._available:
return False
loop = asyncio.get_event_loop()
try:
if self._config.i2c_address == 0x75: # IP5209
# Enable light-load auto-shutdown and force shutdown
# Register 0x01, set appropriate bits
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self._config.i2c_address,
0x01,
0x29 # Enable auto-shutdown
)
else: # IP5312
# IP5312 has different shutdown mechanism
await loop.run_in_executor(
None,
self._bus.write_byte_data,
self._config.i2c_address,
0x03,
0x08 # Force shutdown
)
return True
except Exception:
return False
# ========== Helpers ==========
def _bcd_to_int(self, bcd: int) -> int:
"""Convert BCD byte to integer."""
return (bcd >> 4) * 10 + (bcd & 0x0F)
def _int_to_bcd(self, value: int) -> int:
"""Convert integer to BCD byte."""
return ((value // 10) << 4) | (value % 10)

View File

@@ -0,0 +1,483 @@
"""UPS Manager for Dangerous Pi.
Handles battery monitoring, safe shutdown triggers,
and battery percentage reporting for various UPS HAT devices.
Supports multiple UPS types via driver architecture:
- PiSugar (PiSugar 2/3 series via daemon)
- I2C Fuel Gauge (MAX17040/MAX17048)
"""
import asyncio
import subprocess
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional, Dict, Any
from .. import config
from .ups_drivers import UPSDriver, PiSugarDriver, I2CFuelGaugeDriver, auto_detect_driver
class BatteryStatus(str, Enum):
"""Battery status enum."""
CHARGING = "charging"
DISCHARGING = "discharging"
FULL = "full"
CRITICAL = "critical"
UNKNOWN = "unknown"
class PowerSource(str, Enum):
"""Power source enum."""
AC = "ac"
BATTERY = "battery"
UNKNOWN = "unknown"
@dataclass
class UPSStatus:
"""UPS status information."""
battery_percentage: float
voltage: float
current: float
power_source: PowerSource
battery_status: BatteryStatus
time_remaining: Optional[int] = None # Minutes remaining on battery
temperature: Optional[float] = None
last_updated: Optional[str] = None
is_available: bool = True
error_message: Optional[str] = None
class UPSManager:
"""Manages UPS battery monitoring and safe shutdown."""
def __init__(self, driver: Optional[UPSDriver] = None):
"""Initialize the UPS manager.
Args:
driver: UPS driver instance. If None, creates driver based on config.
"""
self._check_interval = config.UPS_CHECK_INTERVAL
self._driver = driver or self._create_driver()
self._status = UPSStatus(
battery_percentage=0.0,
voltage=0.0,
current=0.0,
power_source=PowerSource.UNKNOWN,
battery_status=BatteryStatus.UNKNOWN,
is_available=False
)
self._shutdown_threshold = 10.0 # Shutdown at 10% battery
self._warning_threshold = 20.0 # Warning at 20% battery
self._critical_threshold = 15.0 # Critical at 15% battery
self._battery_capacity_mah = 1200 # Default for PiSugar 2 (1200mAh)
self._shutdown_initiated = False
self._config_warning_sent = False # Only send config warning once
self._event_callbacks = []
def _create_driver(self) -> Optional[UPSDriver]:
"""Create UPS driver based on configuration.
Returns:
Appropriate UPS driver instance, or None for auto/none types
"""
ups_type = config.UPS_TYPE.lower()
if ups_type == "pisugar":
return PiSugarDriver(
host=config.UPS_PISUGAR_HOST,
port=config.UPS_PISUGAR_PORT
)
elif ups_type == "i2c":
i2c_address = int(config.UPS_I2C_ADDRESS, 16)
return I2CFuelGaugeDriver(i2c_address=i2c_address)
elif ups_type == "auto":
# Auto-detection happens in initialize()
return None
elif ups_type == "none":
# No UPS configured
return None
else:
# Default to auto-detection for unknown types
print(f"Unknown UPS type '{ups_type}', will auto-detect")
return None
async def initialize(self) -> bool:
"""Initialize connection to UPS hardware.
Returns:
True if initialization successful, False otherwise
"""
try:
# If no driver set, try auto-detection (for "auto" or unknown types)
if self._driver is None:
ups_type = config.UPS_TYPE.lower()
if ups_type == "none":
# Explicitly disabled
print("UPS disabled by configuration (UPS_TYPE=none)")
self._status.is_available = False
self._status.error_message = "UPS disabled"
return False
# Run auto-detection
print("Auto-detecting UPS hardware...")
driver, message = await auto_detect_driver(
pisugar_host=config.UPS_PISUGAR_HOST,
pisugar_port=config.UPS_PISUGAR_PORT
)
if driver:
self._driver = driver
print(message)
else:
print(message)
self._status.is_available = False
self._status.error_message = message
return False
# Initialize the driver
success = await self._driver.initialize()
if success:
self._status.is_available = True
self._status.error_message = None
print(f"UPS initialized: {self._driver.get_model_name()}")
else:
self._status.is_available = False
self._status.error_message = f"Failed to initialize {self._driver.get_model_name()}"
return success
except Exception as e:
self._status.is_available = False
self._status.error_message = f"Failed to initialize UPS: {e}"
return False
async def start_monitoring(self):
"""Start periodic battery monitoring in background."""
if not await self.initialize():
print(f"UPS not available: {self._status.error_message}")
return
while True:
try:
await self._update_battery_status()
await self._check_battery_thresholds()
await asyncio.sleep(self._check_interval)
except asyncio.CancelledError:
break
except Exception as e:
print(f"Error in UPS monitoring: {e}")
self._status.error_message = str(e)
await asyncio.sleep(self._check_interval)
async def get_status(self) -> UPSStatus:
"""Get current UPS status.
Returns:
UPSStatus object with current battery information
"""
if self._status.is_available:
await self._update_battery_status()
return self._status
def get_power_restrictions(self) -> Dict[str, Any]:
"""Get current power restrictions based on hardware state.
Returns:
Dict with restriction info including:
- restricted: bool - Whether operations are restricted
- reason: Optional[str] - Why operations are restricted
- power_source: str - Current power source
- ups_available: bool - Whether UPS hardware is present
- battery_percentage: Optional[float] - Current battery level
- allow_firmware_flash: bool - Can flash firmware (fullimage)
- allow_bootloader_flash: bool - Can flash bootloader
- allow_intensive_operations: bool - Can run intensive ops
- message: Optional[str] - User-friendly message
- warning: Optional[str] - Warning message
- shutdown_imminent: Optional[bool] - Shutdown about to happen
"""
# UPS not available = assume AC power, no restrictions
if not self._status.is_available:
return {
"restricted": False,
"reason": None,
"power_source": "assumed_ac",
"ups_available": False,
"battery_percentage": None,
"allow_firmware_flash": True,
"allow_bootloader_flash": True,
"allow_intensive_operations": True,
"message": "UPS not detected. Assuming stable AC power. Ensure power stability before firmware operations."
}
# UPS available - check power source and battery state
if self._status.power_source == PowerSource.AC:
return {
"restricted": False,
"reason": None,
"power_source": "ac",
"ups_available": True,
"battery_percentage": self._status.battery_percentage,
"allow_firmware_flash": True,
"allow_bootloader_flash": True,
"allow_intensive_operations": True,
"message": "On AC power. All operations allowed."
}
# On battery power - apply restrictions based on battery level
battery_pct = self._status.battery_percentage
if battery_pct >= 80:
return {
"restricted": False,
"reason": None,
"power_source": "battery",
"ups_available": True,
"battery_percentage": battery_pct,
"allow_firmware_flash": True,
"allow_bootloader_flash": True,
"allow_intensive_operations": True,
"warning": "On battery power. Bootloader flashing not recommended below 80%."
}
elif battery_pct >= 50:
return {
"restricted": True,
"reason": "Battery level below 80%",
"power_source": "battery",
"ups_available": True,
"battery_percentage": battery_pct,
"allow_firmware_flash": True, # Fullimage only
"allow_bootloader_flash": False,
"allow_intensive_operations": True,
"message": "Bootloader flashing disabled. Battery too low (minimum 80% required)."
}
elif battery_pct >= 20:
return {
"restricted": True,
"reason": "Battery level below 50%",
"power_source": "battery",
"ups_available": True,
"battery_percentage": battery_pct,
"allow_firmware_flash": False,
"allow_bootloader_flash": False,
"allow_intensive_operations": True,
"message": "Firmware flashing disabled. Battery too low (minimum 50% required)."
}
elif battery_pct >= 10:
return {
"restricted": True,
"reason": "Battery critically low",
"power_source": "battery",
"ups_available": True,
"battery_percentage": battery_pct,
"allow_firmware_flash": False,
"allow_bootloader_flash": False,
"allow_intensive_operations": False,
"message": "Critical battery level. Read-only mode active."
}
else:
return {
"restricted": True,
"reason": "Battery emergency level",
"power_source": "battery",
"ups_available": True,
"battery_percentage": battery_pct,
"allow_firmware_flash": False,
"allow_bootloader_flash": False,
"allow_intensive_operations": False,
"message": "Emergency battery level. System will shutdown soon.",
"shutdown_imminent": True
}
async def shutdown_device(self, delay: int = 30):
"""Initiate safe shutdown of the device.
Args:
delay: Delay in seconds before shutdown (default: 30)
"""
if self._shutdown_initiated:
return
self._shutdown_initiated = True
# Notify all registered callbacks
await self._trigger_event("shutdown_initiated", {
"delay": delay,
"battery_percentage": self._status.battery_percentage
})
# Wait for the delay
await asyncio.sleep(delay)
# Execute shutdown command
try:
subprocess.run(["sudo", "shutdown", "-h", "now"], check=True)
except Exception as e:
print(f"Failed to shutdown: {e}")
self._shutdown_initiated = False
def register_event_callback(self, callback):
"""Register a callback for UPS events.
Args:
callback: Async function to call on events
"""
self._event_callbacks.append(callback)
async def _update_battery_status(self):
"""Update battery status from UPS hardware."""
if not self._status.is_available:
return
try:
# Read data from driver
data = await self._driver.read_data()
# Check for misconfigured UPS (all values are zero) - only notify once
if data.percentage == 0.0 and data.voltage == 0.0 and data.current == 0.0:
if not self._config_warning_sent:
self._config_warning_sent = True
model_name = self._driver.get_model_name() if self._driver else "Unknown"
await self._trigger_event("ups_config_required", {
"model": model_name,
"message": f"UPS '{model_name}' detected but returning no data. May need reconfiguration.",
"hint": "Run 'sudo dpkg-reconfigure pisugar-server' to select the correct PiSugar model."
})
else:
# Reset flag when we get valid data
self._config_warning_sent = False
# Update status with new data
self._status.battery_percentage = data.percentage
self._status.voltage = data.voltage
self._status.current = data.current
self._status.temperature = data.temperature
# Calculate time_remaining if not provided by driver
if data.time_remaining is not None:
self._status.time_remaining = data.time_remaining
elif data.current < 0 and data.percentage > 0:
# Discharging - estimate time remaining
# current is in Amps (negative when discharging)
draw_ma = abs(data.current * 1000)
if draw_ma > 10: # Only calculate if meaningful draw
remaining_mah = self._battery_capacity_mah * (data.percentage / 100)
hours_remaining = remaining_mah / draw_ma
self._status.time_remaining = int(hours_remaining * 60) # Minutes
else:
self._status.time_remaining = None
else:
self._status.time_remaining = None
# Determine power source and battery status from driver data
if data.is_charging:
self._status.power_source = PowerSource.AC
if data.percentage >= 99.0:
self._status.battery_status = BatteryStatus.FULL
else:
self._status.battery_status = BatteryStatus.CHARGING
else:
self._status.power_source = PowerSource.BATTERY
if data.percentage < self._critical_threshold:
self._status.battery_status = BatteryStatus.CRITICAL
else:
self._status.battery_status = BatteryStatus.DISCHARGING
self._status.last_updated = datetime.utcnow().isoformat()
self._status.error_message = None
except Exception as e:
print(f"Error reading battery data: {e}")
self._status.error_message = str(e)
async def _check_battery_thresholds(self):
"""Check battery levels and trigger actions."""
percentage = self._status.battery_percentage
power_source = self._status.power_source
# Only check thresholds when on battery power
if power_source != PowerSource.BATTERY:
self._shutdown_initiated = False
return
# Critical threshold - initiate shutdown
if percentage <= self._shutdown_threshold and not self._shutdown_initiated:
await self._trigger_event("battery_critical", {
"percentage": percentage,
"action": "shutdown_initiated"
})
await self.shutdown_device(delay=60) # 60 second warning
# Warning threshold
elif percentage <= self._warning_threshold:
await self._trigger_event("battery_warning", {
"percentage": percentage,
"threshold": self._warning_threshold
})
# Critical threshold (but not shutdown yet)
elif percentage <= self._critical_threshold:
await self._trigger_event("battery_low", {
"percentage": percentage,
"threshold": self._critical_threshold
})
async def _trigger_event(self, event_type: str, data: Dict[str, Any]):
"""Trigger event callbacks.
Args:
event_type: Type of event (e.g., "battery_warning")
data: Event data
"""
event = {
"type": event_type,
"timestamp": datetime.utcnow().isoformat(),
"data": data
}
# Call all registered callbacks
for callback in self._event_callbacks:
try:
await callback(event)
except Exception as e:
print(f"Error in event callback: {e}")
def set_shutdown_threshold(self, threshold: float):
"""Set battery percentage threshold for automatic shutdown.
Args:
threshold: Battery percentage (0-100)
"""
if 0 <= threshold <= 100:
self._shutdown_threshold = threshold
def set_warning_threshold(self, threshold: float):
"""Set battery percentage threshold for warnings.
Args:
threshold: Battery percentage (0-100)
"""
if 0 <= threshold <= 100:
self._warning_threshold = threshold
def close(self):
"""Close UPS driver connection."""
if self._driver:
self._driver.close()
# Global UPS manager instance
_ups_manager: Optional[UPSManager] = None
def get_ups_manager() -> UPSManager:
"""Get the global UPS manager instance."""
global _ups_manager
if _ups_manager is None:
_ups_manager = UPSManager()
return _ups_manager

View File

@@ -0,0 +1 @@
"""Database models."""

View File

@@ -0,0 +1,111 @@
"""Database models and initialization."""
import aiosqlite
from pathlib import Path
from .. import config
async def init_db():
"""Initialize the SQLite database."""
# Ensure data directory exists
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(config.DATABASE_PATH) as db:
# Devices table (NEW for multi-device support)
await db.execute("""
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
device_path TEXT NOT NULL,
serial_number TEXT,
friendly_name TEXT,
usb_vid TEXT,
usb_pid TEXT,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
firmware_version TEXT,
bootrom_version TEXT,
metadata TEXT,
version_mismatch_ignored BOOLEAN DEFAULT FALSE,
ignored_at TIMESTAMP,
ignored_version TEXT
)
""")
# Sessions table (UPDATED for multi-device support)
await db.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
device_id TEXT,
device_path TEXT,
client_ip TEXT NOT NULL,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
released_at TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (device_id) REFERENCES devices(device_id)
)
""")
# Config table
await db.execute("""
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Crash reports table
await db.execute("""
CREATE TABLE IF NOT EXISTS crash_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
error_type TEXT NOT NULL,
error_message TEXT NOT NULL,
traceback TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Command history table
await db.execute("""
CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
command TEXT NOT NULL,
response TEXT,
success BOOLEAN,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
)
""")
# Firmware flash log table (NEW for firmware update tracking)
await db.execute("""
CREATE TABLE IF NOT EXISTS firmware_flash_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
device_path TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
old_version TEXT,
new_version TEXT,
flash_bootrom BOOLEAN DEFAULT FALSE,
success BOOLEAN,
error_message TEXT,
duration_seconds INTEGER,
user_ip TEXT,
FOREIGN KEY (device_id) REFERENCES devices(device_id)
)
""")
await db.commit()
async def get_db():
"""Get database connection."""
db = await aiosqlite.connect(config.DATABASE_PATH)
db.row_factory = aiosqlite.Row
try:
yield db
finally:
await db.close()

View File

@@ -0,0 +1,35 @@
"""Service layer for Dangerous Pi.
The service layer provides reusable business logic that can be consumed by
multiple interfaces (REST API, BLE GATT, plugins, etc.).
Services handle:
- Business logic and validation
- Session management
- Error handling and formatting
- Cross-cutting concerns
This pattern prevents code duplication across interfaces.
"""
from .pm3_service import PM3Service, PM3ServiceResult, PM3ServiceError
from .system_service import SystemService
from .wifi_service import WiFiService
from .update_service import UpdateService
from .container import ServiceContainer, container
__all__ = [
# PM3 Service
"PM3Service",
"PM3ServiceResult",
"PM3ServiceError",
# Other Services
"SystemService",
"WiFiService",
"UpdateService",
# Service Container
"ServiceContainer",
"container",
]

View File

@@ -0,0 +1,192 @@
"""Service Container for Dependency Injection.
This module provides a singleton container that manages service instances
and their dependencies, ensuring consistent state across all interfaces
(REST API, BLE GATT, plugins, etc.).
"""
from typing import Optional
from ..workers.pm3_worker import PM3Worker, SubprocessPM3Worker
from ..managers.session_manager import SessionManager
from ..managers.wifi_manager import WiFiManager
from ..managers.update_manager import get_update_manager
from ..managers.pm3_device_manager import PM3DeviceManager
from .pm3_service import PM3Service
from .system_service import SystemService
from .wifi_service import WiFiService
from .update_service import UpdateService
class ServiceContainer:
"""Dependency injection container for services.
This container:
- Creates and manages singleton instances of services
- Injects shared dependencies (workers, managers)
- Ensures consistent state across all interfaces
- Provides centralized access to services
Usage:
# In REST API
from app.backend.services.container import container
result = await container.pm3_service.execute_command(...)
# In BLE GATT
from app.backend.services.container import container
result = await container.pm3_service.execute_command(...)
# Both use the same service instance!
"""
_instance: Optional['ServiceContainer'] = None
def __init__(self):
"""Initialize service container with shared dependencies."""
if ServiceContainer._instance is not None:
raise RuntimeError(
"ServiceContainer is a singleton. Use container.get_instance() "
"or import the global 'container' instance."
)
# Create shared worker/manager instances
# These are the low-level components that services will use
# Use PM3Worker with SWIG bindings for better performance
# Falls back to SubprocessPM3Worker if SWIG import fails
try:
self._pm3_worker = PM3Worker() # Try SWIG bindings first
except Exception:
self._pm3_worker = SubprocessPM3Worker() # Fallback to subprocess
self._pm3_device_manager = PM3DeviceManager() # NEW: Multi-device manager
self._session_manager = SessionManager()
self._wifi_manager = WiFiManager()
self._update_manager = get_update_manager()
# Create service instances with injected dependencies
self._pm3_service = PM3Service(
pm3_worker=self._pm3_worker,
session_manager=self._session_manager,
device_manager=self._pm3_device_manager # NEW: Multi-device support
)
self._system_service = SystemService()
self._wifi_service = WiFiService(
wifi_manager=self._wifi_manager
)
self._update_service = UpdateService(
update_manager=self._update_manager
)
# Note: WorkflowService will be added in Phase 5
# self._workflow_service = WorkflowService(
# pm3_service=self._pm3_service
# )
@classmethod
def get_instance(cls) -> 'ServiceContainer':
"""Get the singleton service container instance.
Returns:
ServiceContainer instance
"""
if cls._instance is None:
cls._instance = ServiceContainer()
return cls._instance
@classmethod
def reset_instance(cls):
"""Reset the singleton instance.
This is primarily used for testing purposes.
"""
cls._instance = None
# Service properties (read-only access)
@property
def pm3_service(self) -> PM3Service:
"""Get PM3 service instance.
Returns:
Shared PM3Service instance
"""
return self._pm3_service
@property
def system_service(self) -> SystemService:
"""Get system service instance.
Returns:
Shared SystemService instance
"""
return self._system_service
@property
def wifi_service(self) -> WiFiService:
"""Get WiFi service instance.
Returns:
Shared WiFiService instance
"""
return self._wifi_service
@property
def update_service(self) -> UpdateService:
"""Get update service instance.
Returns:
Shared UpdateService instance
"""
return self._update_service
# Manager/Worker access (for cases where direct access is needed)
@property
def pm3_worker(self) -> PM3Worker:
"""Get PM3 worker instance.
Returns:
Shared PM3Worker instance
"""
return self._pm3_worker
@property
def session_manager(self) -> SessionManager:
"""Get session manager instance.
Returns:
Shared SessionManager instance
"""
return self._session_manager
@property
def wifi_manager(self) -> WiFiManager:
"""Get WiFi manager instance.
Returns:
Shared WiFiManager instance
"""
return self._wifi_manager
@property
def pm3_device_manager(self) -> PM3DeviceManager:
"""Get PM3 device manager instance.
Returns:
Shared PM3DeviceManager instance for multi-device support
"""
return self._pm3_device_manager
# Global singleton instance
# Import this throughout the codebase for consistent service access
container = ServiceContainer.get_instance()
# Convenience exports for common services
__all__ = [
'ServiceContainer',
'container',
]

View File

@@ -0,0 +1,585 @@
"""PM3 Service Layer.
This service encapsulates all PM3-related business logic and is used by
both REST API and BLE GATT handlers to avoid code duplication.
"""
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from ..workers.pm3_worker import PM3Worker, PM3CommandResult
from ..managers.session_manager import SessionManager
from ..managers.pm3_device_manager import PM3DeviceManager, PM3Device, DeviceStatus
@dataclass
class PM3ServiceError:
"""Error response from PM3 service."""
code: str # "session_locked", "pm3_not_connected", "command_failed"
message: str
details: Optional[str] = None
@dataclass
class PM3ServiceResult:
"""Result from PM3 service operations."""
success: bool
data: Optional[Dict[str, Any]] = None
error: Optional[PM3ServiceError] = None
class PM3Service:
"""PM3 service layer for command execution and status queries.
This service can be used by multiple interfaces:
- REST API (FastAPI endpoints)
- BLE GATT (Bluetooth handlers)
- Plugin system (future)
It encapsulates:
- Session validation
- PM3 command execution
- Session management
- Response formatting
"""
def __init__(
self,
pm3_worker: Optional[PM3Worker] = None,
session_manager: Optional[SessionManager] = None,
device_manager: Optional[PM3DeviceManager] = None
):
"""Initialize PM3 service.
Args:
pm3_worker: PM3 worker instance (legacy, kept for backward compatibility)
session_manager: Session manager instance (creates new if not provided)
device_manager: PM3 device manager for multi-device support (optional)
"""
self.pm3_worker = pm3_worker or PM3Worker() # Legacy single-device support
self.session_manager = session_manager or SessionManager()
self.device_manager = device_manager # Multi-device support (optional)
async def execute_command(
self,
command: str,
session_id: Optional[str] = None,
timeout: Optional[int] = None,
device_id: Optional[str] = None
) -> PM3ServiceResult:
"""Execute a PM3 command with session validation.
This method handles:
1. Session validation
2. Command execution
3. Session activity updates
4. Error handling
Args:
command: PM3 command to execute
session_id: Optional session ID for multi-user support
timeout: Optional command timeout in seconds
device_id: Optional device ID for multi-device support (uses legacy worker if not provided)
Returns:
PM3ServiceResult with success status and data/error
"""
# Determine which worker to use
worker = None
device_path = None
if device_id and self.device_manager:
# Multi-device mode: get device from device manager
device = await self.device_manager.get_device(device_id)
if not device:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_not_found",
message=f"Device {device_id} not found",
details="Device may have been disconnected"
)
)
if not device.worker:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_no_worker",
message=f"Device {device_id} has no worker instance",
details="Device may not be fully initialized"
)
)
worker = device.worker
device_path = device.device_path
else:
# Legacy single-device mode
worker = self.pm3_worker
device_path = self.pm3_worker.device_path
# 1. Validate session (per-device)
if not self.session_manager.can_execute(session_id, device_id):
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="session_locked",
message=f"Another session is active for device {device_id or 'default'}",
details="Please take over the session or wait for it to expire"
)
)
# 2. Check PM3 connection
if not await worker.is_connected():
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="pm3_not_connected",
message="Proxmark3 not connected",
details=f"Device path: {device_path}"
)
)
# 3. Execute command
try:
result = await worker.execute_command(command)
# 4. Update session activity
if session_id:
self.session_manager.update_activity(session_id, device_id)
# 5. Return result
if result.success:
return PM3ServiceResult(
success=True,
data={
"output": result.output,
"command": command,
"session_id": session_id,
"device_id": device_id
}
)
else:
# Include both error message and output for better diagnostics
# PM3 CLI often outputs error details to stdout
error_details = result.error or ""
if result.output:
error_details = f"{error_details}\nOutput: {result.output}" if error_details else result.output
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="command_failed",
message="PM3 command failed",
details=error_details
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="execution_error",
message="Command execution error",
details=str(e)
)
)
async def get_status(self, device_id: Optional[str] = None) -> PM3ServiceResult:
"""Get PM3 device status.
Args:
device_id: Optional device ID. If None and device_manager exists, returns all devices.
If None and no device_manager, returns legacy single device status.
Returns:
PM3ServiceResult with connection status, version, etc.
"""
try:
# Multi-device mode: return all devices or specific device
if device_id is None and self.device_manager:
# Return status for all devices
devices = await self.device_manager.get_all_devices()
return PM3ServiceResult(
success=True,
data={
"devices": [
{
"device_id": d.device_id,
"device_path": d.device_path,
"friendly_name": d.friendly_name or d.device_path.split('/')[-1],
"serial_number": d.serial_number,
"connected": d.status == DeviceStatus.CONNECTED,
"in_use": d.status == DeviceStatus.IN_USE,
"status": d.status.value,
"firmware_info": {
"bootrom_version": d.firmware_info.bootrom_version,
"os_version": d.firmware_info.os_version,
"compatible": d.firmware_info.compatible,
},
"last_seen": d.last_seen.isoformat(),
}
for d in devices
]
}
)
elif device_id and self.device_manager:
# Return status for specific device
device = await self.device_manager.get_device(device_id)
if not device:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_not_found",
message=f"Device {device_id} not found"
)
)
return PM3ServiceResult(
success=True,
data={
"device_id": device.device_id,
"device_path": device.device_path,
"friendly_name": device.friendly_name or device.device_path.split('/')[-1],
"serial_number": device.serial_number,
"connected": device.status == DeviceStatus.CONNECTED,
"in_use": device.status == DeviceStatus.IN_USE,
"status": device.status.value,
"firmware_info": {
"bootrom_version": device.firmware_info.bootrom_version,
"os_version": device.firmware_info.os_version,
"compatible": device.firmware_info.compatible,
},
"last_seen": device.last_seen.isoformat(),
}
)
else:
# Legacy single-device mode
is_connected = await self.pm3_worker.is_connected()
version = None
if is_connected:
# Try to get version info
result = await self.pm3_worker.execute_command("hw version")
if result.success:
version = result.output.split("\n")[0] if result.output else None
return PM3ServiceResult(
success=True,
data={
"connected": is_connected,
"device_path": self.pm3_worker.device_path,
"version": version,
"session_active": self.session_manager.has_active_session()
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="status_error",
message="Failed to get PM3 status",
details=str(e)
)
)
async def connect(self) -> PM3ServiceResult:
"""Connect to PM3 device.
Returns:
PM3ServiceResult indicating success/failure
"""
try:
await self.pm3_worker.connect()
return PM3ServiceResult(
success=True,
data={"message": "Connected to Proxmark3"}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="connection_error",
message="Failed to connect to PM3",
details=str(e)
)
)
async def disconnect(self) -> PM3ServiceResult:
"""Disconnect from PM3 device.
Returns:
PM3ServiceResult indicating success/failure
"""
try:
await self.pm3_worker.disconnect()
return PM3ServiceResult(
success=True,
data={"message": "Disconnected from Proxmark3"}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="disconnection_error",
message="Failed to disconnect from PM3",
details=str(e)
)
)
# Session management methods (for both REST and BLE)
async def create_session(
self,
client_ip: str = "unknown",
user_agent: Optional[str] = None,
force_takeover: bool = False,
device_id: Optional[str] = None
) -> PM3ServiceResult:
"""Create a new session for a device.
Args:
client_ip: Client IP address
user_agent: Client user agent string
force_takeover: Whether to forcefully take over existing session
device_id: Device ID to create session for (None for legacy mode)
Returns:
PM3ServiceResult with session_id
"""
success, session_id, error_msg = await self.session_manager.create_session(
client_ip=client_ip,
user_agent=user_agent,
force_takeover=force_takeover,
device_id=device_id
)
if success and session_id:
return PM3ServiceResult(
success=True,
data={"session_id": session_id, "device_id": device_id}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="session_exists",
message=error_msg or "Session already exists",
details="Set force_takeover=true to take over"
)
)
async def release_session(
self,
session_id: str,
device_id: Optional[str] = None
) -> PM3ServiceResult:
"""Release a session.
Args:
session_id: Session ID to release
device_id: Device ID (if known)
Returns:
PM3ServiceResult indicating success
"""
released = await self.session_manager.release_session(session_id, device_id)
if released:
return PM3ServiceResult(
success=True,
data={"message": "Session released"}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="session_not_found",
message="Session not found or already released"
)
)
def get_session_info(self, device_id: Optional[str] = None) -> PM3ServiceResult:
"""Get session information for a device.
Args:
device_id: Device ID to get session for (None for any active session)
Returns:
PM3ServiceResult with session details
"""
session = self.session_manager.get_active_session(device_id)
if session:
return PM3ServiceResult(
success=True,
data={
"session_id": session.session_id,
"device_id": session.device_id,
"client_ip": session.client_ip,
"created_at": session.created_at,
"last_activity": session.last_activity,
}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="session_not_found",
message=f"No active session for device {device_id or 'default'}"
)
)
def get_all_sessions(self) -> PM3ServiceResult:
"""Get all active sessions.
Returns:
PM3ServiceResult with all session details
"""
sessions = self.session_manager.get_all_active_sessions()
return PM3ServiceResult(
success=True,
data={
"sessions": [
{
"session_id": s.session_id,
"device_id": s.device_id,
"client_ip": s.client_ip,
"created_at": s.created_at,
"last_activity": s.last_activity,
}
for s in sessions.values()
]
}
)
# Multi-device management methods
async def list_devices(self) -> PM3ServiceResult:
"""List all discovered PM3 devices.
Returns:
PM3ServiceResult with list of all devices
"""
if not self.device_manager:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_manager_not_available",
message="Device manager not initialized",
details="Multi-device support is not enabled"
)
)
try:
devices = await self.device_manager.get_all_devices()
return PM3ServiceResult(
success=True,
data={
"devices": [device.to_dict() for device in devices]
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="list_devices_error",
message="Failed to list devices",
details=str(e)
)
)
async def get_available_devices(self) -> PM3ServiceResult:
"""Get devices without active sessions (available for use).
Returns:
PM3ServiceResult with list of available devices
"""
if not self.device_manager:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_manager_not_available",
message="Device manager not initialized",
details="Multi-device support is not enabled"
)
)
try:
all_devices = await self.device_manager.get_all_devices()
# Filter for devices that are CONNECTED (not IN_USE or other states)
available_devices = [
device for device in all_devices
if device.status == DeviceStatus.CONNECTED
]
return PM3ServiceResult(
success=True,
data={
"devices": [device.to_dict() for device in available_devices]
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="get_available_devices_error",
message="Failed to get available devices",
details=str(e)
)
)
async def identify_device(
self,
device_id: str,
duration_ms: int = 2000
) -> PM3ServiceResult:
"""Blink LEDs on a device for physical identification.
Args:
device_id: Device ID to identify
duration_ms: Duration to blink LEDs in milliseconds (default: 2000)
Returns:
PM3ServiceResult indicating success
"""
if not self.device_manager:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_manager_not_available",
message="Device manager not initialized",
details="Multi-device support is not enabled"
)
)
try:
# Check if device exists
device = await self.device_manager.get_device(device_id)
if not device:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="device_not_found",
message=f"Device {device_id} not found"
)
)
# Trigger LED identification
await self.device_manager.identify_device(device_id, duration_ms)
return PM3ServiceResult(
success=True,
data={
"message": f"Device {device_id} identified",
"device_path": device.device_path,
"duration_ms": duration_ms
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="identify_device_error",
message="Failed to identify device",
details=str(e)
)
)

View File

@@ -0,0 +1,789 @@
"""System Service Layer.
This service provides system operations (shutdown, restart, info) that can be
consumed by multiple interfaces (REST API, BLE GATT, etc.).
"""
import asyncio
import os
import platform
import psutil
import shutil
from dataclasses import dataclass
from typing import Optional, Dict, Any
from pathlib import Path
from .pm3_service import PM3ServiceError, PM3ServiceResult
@dataclass
class CPUCoreInfo:
"""Per-core CPU information."""
core_id: int
percent: float
online: bool = True # Whether this core is currently online
@dataclass
class PiModelInfo:
"""Raspberry Pi model information."""
model: str # Full model string (e.g., "Raspberry Pi Zero 2 W Rev 1.0")
model_short: str # Short name (e.g., "Pi Zero 2 W")
total_cores: int # Total physical cores
default_active_cores: int # Recommended default active cores
min_cores: int # Minimum cores (always 1)
max_cores: int # Maximum configurable cores
@dataclass
class CPUCoresConfig:
"""CPU cores configuration."""
total_cores: int
online_cores: int
configurable_cores: list # List of core IDs that can be toggled (usually all except 0)
pi_model: Optional[PiModelInfo] = None
@dataclass
class SystemInfo:
"""System information data."""
hostname: str
platform: str
platform_version: str
cpu_count: int
cpu_percent: float
cpu_per_core: list # List of CPUCoreInfo
memory_total: int
memory_used: int
memory_percent: float
disk_total: int
disk_used: int
disk_percent: float
uptime: float
temperature: Optional[float] = None
load_average: Optional[tuple] = None # 1, 5, 15 min load averages
class SystemService:
"""System operations service.
Provides reusable business logic for:
- System information queries
- Shutdown/restart operations
- Service log retrieval
"""
def __init__(self):
"""Initialize system service."""
pass
async def get_info(self) -> PM3ServiceResult:
"""Get system information.
Returns:
PM3ServiceResult with system info (CPU, memory, disk, etc.)
"""
try:
# Get CPU usage (blocking call, run in executor)
loop = asyncio.get_event_loop()
# Get per-core CPU percentages (requires percpu=True)
# First call to initialize, then wait briefly for measurement
await loop.run_in_executor(None, lambda: psutil.cpu_percent(percpu=True))
await asyncio.sleep(0.1) # Brief pause for accurate measurement
cpu_per_core_raw = await loop.run_in_executor(
None,
lambda: psutil.cpu_percent(percpu=True)
)
# Check which cores are online
total_cores = psutil.cpu_count(logical=True) or 1
core_online_status = {}
for i in range(total_cores):
online_file = Path(f"/sys/devices/system/cpu/cpu{i}/online")
if i == 0:
# Core 0 is always online
core_online_status[i] = True
elif online_file.exists():
core_online_status[i] = online_file.read_text().strip() == "1"
else:
# File doesn't exist, core is always on
core_online_status[i] = True
# Build per-core info with online status
cpu_per_core = [
CPUCoreInfo(
core_id=i,
percent=pct if core_online_status.get(i, True) else 0.0,
online=core_online_status.get(i, True)
)
for i, pct in enumerate(cpu_per_core_raw)
]
# Overall CPU percentage
cpu_percent = sum(cpu_per_core_raw) / len(cpu_per_core_raw) if cpu_per_core_raw else 0.0
# Get load average (Unix only)
try:
load_average = os.getloadavg()
except (OSError, AttributeError):
load_average = None
# Get memory info
memory = psutil.virtual_memory()
# Get disk info for root partition
disk = psutil.disk_usage('/')
# Get system uptime
boot_time = psutil.boot_time()
uptime = psutil.time.time() - boot_time
# Try to get CPU temperature (Raspberry Pi specific)
temperature = await self._get_cpu_temperature()
system_info = SystemInfo(
hostname=platform.node(),
platform=platform.system(),
platform_version=platform.release(),
cpu_count=psutil.cpu_count(),
cpu_percent=cpu_percent,
cpu_per_core=cpu_per_core,
memory_total=memory.total,
memory_used=memory.used,
memory_percent=memory.percent,
disk_total=disk.total,
disk_used=disk.used,
disk_percent=disk.percent,
uptime=uptime,
temperature=temperature,
load_average=load_average
)
return PM3ServiceResult(
success=True,
data={
"hostname": system_info.hostname,
"platform": system_info.platform,
"platform_version": system_info.platform_version,
"cpu": {
"count": system_info.cpu_count,
"percent": system_info.cpu_percent,
"temperature": system_info.temperature,
"per_core": [
{"core_id": c.core_id, "percent": c.percent, "online": c.online}
for c in system_info.cpu_per_core
],
"load_average": list(system_info.load_average) if system_info.load_average else None
},
"memory": {
"total": system_info.memory_total,
"used": system_info.memory_used,
"percent": system_info.memory_percent
},
"disk": {
"total": system_info.disk_total,
"used": system_info.disk_used,
"percent": system_info.disk_percent
},
"uptime": system_info.uptime
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="system_info_error",
message="Failed to get system information",
details=str(e)
)
)
async def _get_cpu_temperature(self) -> Optional[float]:
"""Get CPU temperature (Raspberry Pi specific).
Returns:
Temperature in Celsius or None if unavailable
"""
try:
temp_file = Path("/sys/class/thermal/thermal_zone0/temp")
if temp_file.exists():
temp_str = temp_file.read_text().strip()
# Temperature is in millidegrees
return float(temp_str) / 1000.0
except Exception:
pass
return None
async def shutdown(self, delay: int = 0) -> PM3ServiceResult:
"""Initiate system shutdown.
Args:
delay: Delay in seconds before shutdown (default: 0)
Returns:
PM3ServiceResult indicating success/failure
"""
try:
if delay > 0:
# Schedule shutdown
await self._run_command(f"sudo shutdown -h +{delay // 60}")
return PM3ServiceResult(
success=True,
data={
"message": f"Shutdown scheduled in {delay} seconds",
"delay": delay
}
)
else:
# Immediate shutdown
await self._run_command("sudo shutdown -h now")
return PM3ServiceResult(
success=True,
data={"message": "Shutdown initiated"}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="shutdown_error",
message="Failed to initiate shutdown",
details=str(e)
)
)
async def restart(self, delay: int = 0) -> PM3ServiceResult:
"""Initiate system restart.
Args:
delay: Delay in seconds before restart (default: 0)
Returns:
PM3ServiceResult indicating success/failure
"""
try:
if delay > 0:
# Schedule restart
await self._run_command(f"sudo shutdown -r +{delay // 60}")
return PM3ServiceResult(
success=True,
data={
"message": f"Restart scheduled in {delay} seconds",
"delay": delay
}
)
else:
# Immediate restart
await self._run_command("sudo shutdown -r now")
return PM3ServiceResult(
success=True,
data={"message": "Restart initiated"}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="restart_error",
message="Failed to initiate restart",
details=str(e)
)
)
async def cancel_shutdown(self) -> PM3ServiceResult:
"""Cancel a scheduled shutdown or restart.
Returns:
PM3ServiceResult indicating success/failure
"""
try:
await self._run_command("sudo shutdown -c")
return PM3ServiceResult(
success=True,
data={"message": "Shutdown/restart cancelled"}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="cancel_shutdown_error",
message="Failed to cancel shutdown",
details=str(e)
)
)
async def get_logs(
self,
service: str = "dangerous-pi",
lines: int = 100
) -> PM3ServiceResult:
"""Get service logs using journalctl.
Args:
service: Service name to get logs for
lines: Number of lines to retrieve (default: 100)
Returns:
PM3ServiceResult with log content
"""
try:
output = await self._run_command(
f"sudo journalctl -u {service} -n {lines} --no-pager"
)
return PM3ServiceResult(
success=True,
data={
"service": service,
"lines": lines,
"logs": output
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="logs_error",
message=f"Failed to get logs for service '{service}'",
details=str(e)
)
)
async def get_service_status(self, service: str) -> PM3ServiceResult:
"""Get systemd service status.
Args:
service: Service name
Returns:
PM3ServiceResult with service status
"""
try:
output = await self._run_command(
f"sudo systemctl status {service}",
check=False # Don't raise on non-zero exit
)
# Parse status
is_active = "active (running)" in output.lower()
is_enabled = await self._is_service_enabled(service)
return PM3ServiceResult(
success=True,
data={
"service": service,
"active": is_active,
"enabled": is_enabled,
"status": output
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="service_status_error",
message=f"Failed to get status for service '{service}'",
details=str(e)
)
)
async def _is_service_enabled(self, service: str) -> bool:
"""Check if a systemd service is enabled.
Args:
service: Service name
Returns:
True if service is enabled
"""
try:
output = await self._run_command(
f"sudo systemctl is-enabled {service}",
check=False
)
return output.strip() == "enabled"
except Exception:
return False
async def _run_command(
self,
command: str,
check: bool = True
) -> str:
"""Run a shell command asynchronously.
Args:
command: Command to run
check: Raise exception on non-zero exit code
Returns:
Command output
Raises:
RuntimeError: If command fails and check=True
"""
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if check and process.returncode != 0:
raise RuntimeError(
f"Command failed with exit code {process.returncode}: "
f"{stderr.decode()}"
)
return stdout.decode()
# Pi model defaults: maps model substring to (short_name, default_cores)
# Pi Zero 2 W has 4 cores but limited thermal/power, default to 2
PI_MODEL_DEFAULTS = {
"Pi Zero 2": ("Pi Zero 2 W", 2),
"Pi Zero W": ("Pi Zero W", 1), # Single core
"Pi Zero": ("Pi Zero", 1), # Single core
"Pi 4": ("Pi 4", 4),
"Pi 3": ("Pi 3", 4),
"Pi 2": ("Pi 2", 4),
"Pi 5": ("Pi 5", 4),
}
async def get_pi_model(self) -> PM3ServiceResult:
"""Detect Raspberry Pi model.
Reads from /proc/device-tree/model or /proc/cpuinfo.
Returns:
PM3ServiceResult with PiModelInfo data
"""
try:
model_str = "Unknown"
model_short = "Unknown"
total_cores = psutil.cpu_count(logical=True) or 1
default_cores = total_cores
# Try device tree first (most reliable)
model_file = Path("/proc/device-tree/model")
if model_file.exists():
model_str = model_file.read_text().strip().rstrip('\x00')
else:
# Fallback to cpuinfo
cpuinfo = Path("/proc/cpuinfo")
if cpuinfo.exists():
for line in cpuinfo.read_text().split('\n'):
if line.startswith("Model"):
model_str = line.split(':')[1].strip()
break
# Determine short name and default cores based on model
for pattern, (short_name, def_cores) in self.PI_MODEL_DEFAULTS.items():
if pattern in model_str:
model_short = short_name
default_cores = min(def_cores, total_cores)
break
else:
# Not a known Pi model, use total cores
if "Raspberry" in model_str:
model_short = "Raspberry Pi"
else:
model_short = model_str[:20] if len(model_str) > 20 else model_str
pi_model = PiModelInfo(
model=model_str,
model_short=model_short,
total_cores=total_cores,
default_active_cores=default_cores,
min_cores=1,
max_cores=total_cores
)
return PM3ServiceResult(
success=True,
data={
"model": pi_model.model,
"model_short": pi_model.model_short,
"total_cores": pi_model.total_cores,
"default_active_cores": pi_model.default_active_cores,
"min_cores": pi_model.min_cores,
"max_cores": pi_model.max_cores
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="pi_model_error",
message="Failed to detect Pi model",
details=str(e)
)
)
async def get_cpu_cores_config(self) -> PM3ServiceResult:
"""Get CPU cores configuration.
Returns info about total cores, online cores, configured cores (from cmdline.txt),
and Pi model with defaults.
Returns:
PM3ServiceResult with CPUCoresConfig data
"""
try:
total_cores = psutil.cpu_count(logical=True) or 1
# Get currently online cores (runtime state)
# On Pi, we can use nproc or count from /proc/cpuinfo
try:
import subprocess
result = subprocess.run(
["nproc"],
capture_output=True,
text=True,
timeout=5
)
online_cores = int(result.stdout.strip()) if result.returncode == 0 else total_cores
except Exception:
online_cores = total_cores
# Get configured cores from cmdline.txt (boot-time setting)
configured_cores = self._get_configured_maxcpus()
# Configurable cores are all cores except 0 (which is always on)
configurable_cores = list(range(1, total_cores))
# Get Pi model info
model_result = await self.get_pi_model()
pi_model_data = model_result.data if model_result.success else None
return PM3ServiceResult(
success=True,
data={
"total_cores": total_cores,
"online_cores": online_cores,
"configured_cores": configured_cores, # From cmdline.txt
"configurable_cores": configurable_cores,
"pi_model": pi_model_data
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="cpu_cores_error",
message="Failed to get CPU cores config",
details=str(e)
)
)
# Possible locations for cmdline.txt (varies by Pi OS version)
CMDLINE_PATHS = [
Path("/boot/firmware/cmdline.txt"), # Newer Pi OS (Bookworm+)
Path("/boot/cmdline.txt"), # Older Pi OS
]
def _find_cmdline_path(self) -> Optional[Path]:
"""Find the cmdline.txt file location.
Returns:
Path to cmdline.txt or None if not found
"""
for path in self.CMDLINE_PATHS:
if path.exists():
return path
return None
def _get_configured_maxcpus(self) -> Optional[int]:
"""Read the maxcpus value from cmdline.txt.
Returns:
Configured maxcpus value, or None if not set
"""
cmdline_path = self._find_cmdline_path()
if not cmdline_path:
return None
try:
content = cmdline_path.read_text().strip()
# Parse cmdline parameters
for param in content.split():
if param.startswith("maxcpus="):
return int(param.split("=")[1])
except Exception:
pass
return None
async def set_cpu_cores(
self,
num_cores: int,
persist: bool = True,
reboot: bool = True
) -> PM3ServiceResult:
"""Set the number of active CPU cores via kernel parameter.
On Pi Zero 2 W (and other ARM systems), CPU hotplug via sysfs doesn't work.
Instead, we modify the maxcpus=N kernel parameter in /boot/cmdline.txt.
This requires a reboot to take effect.
Args:
num_cores: Number of cores to use (1 to total_cores)
persist: Must be True (always persists to cmdline.txt)
reboot: If True, reboot the system immediately
Returns:
PM3ServiceResult indicating success/failure
"""
try:
total_cores = psutil.cpu_count(logical=True) or 1
# Validate input
if num_cores < 1:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="invalid_cores",
message="Must have at least 1 core active",
details=f"Requested: {num_cores}"
)
)
if num_cores > total_cores:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="invalid_cores",
message=f"Cannot exceed {total_cores} cores",
details=f"Requested: {num_cores}, Available: {total_cores}"
)
)
# Find cmdline.txt
cmdline_path = self._find_cmdline_path()
if not cmdline_path:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="cmdline_not_found",
message="Could not find /boot/cmdline.txt or /boot/firmware/cmdline.txt",
details="This feature requires a Raspberry Pi with standard boot configuration"
)
)
# Update cmdline.txt with maxcpus parameter
update_result = await self._update_cmdline_maxcpus(cmdline_path, num_cores)
if not update_result.success:
return update_result
# Reboot if requested
if reboot:
await self._run_command("sudo shutdown -r +0", check=False)
return PM3ServiceResult(
success=True,
data={
"message": f"CPU cores set to {num_cores}. Rebooting...",
"requested": num_cores,
"rebooting": True
}
)
return PM3ServiceResult(
success=True,
data={
"message": f"CPU cores set to {num_cores}. Reboot required to apply.",
"requested": num_cores,
"rebooting": False,
"reboot_required": True
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="set_cores_error",
message="Failed to set CPU cores",
details=str(e)
)
)
async def _update_cmdline_maxcpus(
self,
cmdline_path: Path,
num_cores: int
) -> PM3ServiceResult:
"""Update the maxcpus parameter in cmdline.txt.
Args:
cmdline_path: Path to cmdline.txt
num_cores: Number of cores to set
Returns:
PM3ServiceResult indicating success/failure
"""
try:
# Read current cmdline
content = cmdline_path.read_text().strip()
# Parse and update parameters
params = content.split()
new_params = []
maxcpus_found = False
for param in params:
if param.startswith("maxcpus="):
# Replace existing maxcpus
new_params.append(f"maxcpus={num_cores}")
maxcpus_found = True
else:
new_params.append(param)
# Add maxcpus if not present
if not maxcpus_found:
new_params.append(f"maxcpus={num_cores}")
new_content = " ".join(new_params)
# Backup original
await self._run_command(
f"sudo cp {cmdline_path} {cmdline_path}.bak",
check=True
)
# Write new cmdline.txt
# Use a temp file and move to avoid corruption
await self._run_command(
f"echo '{new_content}' | sudo tee {cmdline_path}.new > /dev/null",
check=True
)
await self._run_command(
f"sudo mv {cmdline_path}.new {cmdline_path}",
check=True
)
return PM3ServiceResult(
success=True,
data={"message": f"Updated {cmdline_path} with maxcpus={num_cores}"}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="cmdline_update_error",
message="Failed to update cmdline.txt",
details=str(e)
)
)
def get_configured_cpu_cores(self) -> Optional[int]:
"""Get the configured CPU cores from cmdline.txt.
Returns:
Configured maxcpus value, or None if not explicitly set
"""
return self._get_configured_maxcpus()

View File

@@ -0,0 +1,312 @@
"""Update Service Layer.
This service provides software update operations that can be consumed by
multiple interfaces (REST API, BLE GATT, etc.).
"""
from typing import Optional, Dict, Any
from ..managers.update_manager import UpdateManager, UpdateProgress, UpdateStatus
from .pm3_service import PM3ServiceError, PM3ServiceResult
class UpdateService:
"""Update service layer for software update management.
This service can be used by multiple interfaces:
- REST API (FastAPI endpoints)
- BLE GATT (Bluetooth handlers)
- Plugin system (future)
It encapsulates:
- Update checking
- Update downloading
- Update installation
- Progress monitoring
"""
def __init__(self, update_manager: Optional[UpdateManager] = None):
"""Initialize update service.
Args:
update_manager: Update manager instance (creates new if not provided)
"""
from ..managers.update_manager import get_update_manager
self.update_manager = update_manager or get_update_manager()
async def check_for_updates(self) -> PM3ServiceResult:
"""Check for available updates.
Returns:
PM3ServiceResult with update availability and information
"""
try:
result = await self.update_manager.check_for_updates()
return PM3ServiceResult(
success=True,
data=result
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="update_check_error",
message="Failed to check for updates",
details=str(e)
)
)
async def download_update(self) -> PM3ServiceResult:
"""Download the latest available update.
Returns:
PM3ServiceResult indicating download success/failure
"""
try:
success = await self.update_manager.download_update()
if success:
return PM3ServiceResult(
success=True,
data={
"message": "Update downloaded successfully",
"ready_to_install": True
}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="download_failed",
message="Update download failed"
)
)
except ValueError as e:
# No update available
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="no_update_available",
message=str(e)
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="update_download_error",
message="Error downloading update",
details=str(e)
)
)
async def install_update(self) -> PM3ServiceResult:
"""Install the downloaded update.
Returns:
PM3ServiceResult indicating installation success/failure
"""
try:
success = await self.update_manager.install_update()
if success:
return PM3ServiceResult(
success=True,
data={
"message": "Update installed successfully",
"requires_restart": True
}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="installation_failed",
message="Update installation failed"
)
)
except ValueError as e:
# No update downloaded
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="no_update_downloaded",
message=str(e)
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="update_install_error",
message="Error installing update",
details=str(e)
)
)
async def get_progress(self) -> PM3ServiceResult:
"""Get current update progress.
Returns:
PM3ServiceResult with progress information
"""
try:
progress = await self.update_manager.get_progress()
return PM3ServiceResult(
success=True,
data={
"status": progress.status.value,
"current_version": progress.current_version,
"available_version": progress.available_version,
"download_progress": progress.download_progress,
"error_message": progress.error_message,
"last_check": progress.last_check
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="progress_error",
message="Failed to get update progress",
details=str(e)
)
)
async def get_release_notes(
self,
version: Optional[str] = None
) -> PM3ServiceResult:
"""Get release notes for a specific version or latest.
Args:
version: Version to get notes for (latest if None)
Returns:
PM3ServiceResult with release notes
"""
try:
notes = await self.update_manager.get_release_notes(version)
return PM3ServiceResult(
success=True,
data={
"version": version or "latest",
"release_notes": notes
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="release_notes_error",
message="Failed to get release notes",
details=str(e)
)
)
async def check_and_download_update(self) -> PM3ServiceResult:
"""Combined operation: check for updates and download if available.
Returns:
PM3ServiceResult with check and download status
"""
try:
# First check for updates
check_result = await self.check_for_updates()
if not check_result.success:
return check_result
# If update available, download it
if check_result.data.get("update_available"):
download_result = await self.download_update()
if download_result.success:
return PM3ServiceResult(
success=True,
data={
"message": "Update checked and downloaded",
"update_info": check_result.data,
"ready_to_install": True
}
)
else:
return download_result
else:
return PM3ServiceResult(
success=True,
data={
"message": "System is up to date",
"update_available": False,
"current_version": check_result.data.get("current_version")
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="check_download_error",
message="Error checking and downloading update",
details=str(e)
)
)
async def full_update(self) -> PM3ServiceResult:
"""Full update workflow: check, download, and install.
Returns:
PM3ServiceResult with complete update status
"""
try:
# Check for updates
check_result = await self.check_for_updates()
if not check_result.success:
return check_result
if not check_result.data.get("update_available"):
return PM3ServiceResult(
success=True,
data={
"message": "System is already up to date",
"update_performed": False
}
)
# Download update
download_result = await self.download_update()
if not download_result.success:
return download_result
# Install update
install_result = await self.install_update()
if not install_result.success:
return install_result
return PM3ServiceResult(
success=True,
data={
"message": "Update completed successfully",
"update_performed": True,
"previous_version": check_result.data.get("current_version"),
"new_version": check_result.data.get("latest_version"),
"requires_restart": True
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="full_update_error",
message="Error performing full update",
details=str(e)
)
)

View File

@@ -0,0 +1,351 @@
"""WiFi Service Layer.
This service provides WiFi management operations that can be consumed by
multiple interfaces (REST API, BLE GATT, etc.).
"""
from typing import Optional, List, Dict, Any
from ..managers.wifi_manager import WiFiManager, WiFiMode, WiFiStatus, WiFiNetwork
from .pm3_service import PM3ServiceError, PM3ServiceResult
class WiFiService:
"""WiFi service layer for network management.
This service can be used by multiple interfaces:
- REST API (FastAPI endpoints)
- BLE GATT (Bluetooth handlers)
- Plugin system (future)
It encapsulates:
- Network scanning
- Connection management
- WiFi mode switching
- Status queries
"""
def __init__(self, wifi_manager: Optional[WiFiManager] = None):
"""Initialize WiFi service.
Args:
wifi_manager: WiFi manager instance (creates new if not provided)
"""
self.wifi_manager = wifi_manager or WiFiManager()
async def get_status(self) -> PM3ServiceResult:
"""Get current WiFi status.
Returns:
PM3ServiceResult with WiFi status (mode, interfaces, connections)
"""
try:
status = await self.wifi_manager.get_status()
return PM3ServiceResult(
success=True,
data={
"mode": status.mode.value,
"interfaces": [
{
"name": iface.name,
"mac": iface.mac,
"is_usb": iface.is_usb,
"is_up": iface.is_up,
"connected": iface.connected,
"ssid": iface.ssid,
"ip_address": iface.ip_address,
"mode": iface.mode # "AP" or "managed"
}
for iface in status.interfaces
],
"client": {
"ssid": status.current_ssid,
"ip": status.current_ip
} if status.current_ssid else None,
"access_point": {
"ssid": status.ap_ssid,
"ip": status.ap_ip
},
"supports_dual": status.supports_dual
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="wifi_status_error",
message="Failed to get WiFi status",
details=str(e)
)
)
async def scan_networks(
self,
interface: Optional[str] = None
) -> PM3ServiceResult:
"""Scan for available WiFi networks.
Args:
interface: Interface to scan with (default: auto-select)
Returns:
PM3ServiceResult with list of available networks
"""
try:
networks = await self.wifi_manager.scan_networks(interface)
return PM3ServiceResult(
success=True,
data={
"networks": [
{
"ssid": net.ssid,
"bssid": net.bssid,
"signal_strength": net.signal_strength,
"frequency": net.frequency,
"encrypted": net.encrypted,
"in_use": net.in_use
}
for net in networks
],
"count": len(networks)
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="wifi_scan_error",
message="Failed to scan for networks",
details=str(e)
)
)
async def connect(
self,
ssid: str,
password: Optional[str] = None,
interface: Optional[str] = None,
hidden: bool = False
) -> PM3ServiceResult:
"""Connect to a WiFi network.
Args:
ssid: Network SSID
password: Network password (if encrypted)
interface: Interface to use (default: auto-select)
hidden: Whether network is hidden
Returns:
PM3ServiceResult indicating connection success/failure
"""
try:
success = await self.wifi_manager.connect_to_network(
ssid=ssid,
password=password,
interface=interface,
hidden=hidden
)
if success:
# Get updated status to include new connection info
status = await self.wifi_manager.get_status()
return PM3ServiceResult(
success=True,
data={
"message": f"Successfully connected to {ssid}",
"ssid": ssid,
"ip": status.current_ip
}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="connection_failed",
message=f"Failed to connect to {ssid}",
details="Connection attempt failed or timed out"
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="wifi_connect_error",
message=f"Error connecting to {ssid}",
details=str(e)
)
)
async def disconnect(
self,
interface: Optional[str] = None
) -> PM3ServiceResult:
"""Disconnect from current network.
Args:
interface: Interface to disconnect (default: first connected)
Returns:
PM3ServiceResult indicating success/failure
"""
try:
success = await self.wifi_manager.disconnect_from_network(interface)
if success:
return PM3ServiceResult(
success=True,
data={"message": "Disconnected from network"}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="disconnect_failed",
message="Failed to disconnect",
details="No active connection found or disconnect failed"
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="wifi_disconnect_error",
message="Error disconnecting from network",
details=str(e)
)
)
async def set_mode(self, mode: str) -> PM3ServiceResult:
"""Set WiFi operation mode.
Args:
mode: WiFi mode ("ap", "client", "dual", "auto", "off")
Returns:
PM3ServiceResult indicating success/failure
"""
try:
# Validate and convert mode string to enum
try:
wifi_mode = WiFiMode(mode)
except ValueError:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="invalid_mode",
message=f"Invalid WiFi mode: {mode}",
details=f"Valid modes: ap, client, dual, auto, off"
)
)
success = await self.wifi_manager.set_mode(wifi_mode)
if success:
return PM3ServiceResult(
success=True,
data={
"message": f"WiFi mode set to {mode}",
"mode": mode
}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="mode_change_failed",
message=f"Failed to set WiFi mode to {mode}",
details="Mode change operation failed"
)
)
except ValueError as e:
# Handle dual mode without USB adapter
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="mode_not_supported",
message=str(e),
details="Dual mode requires USB WiFi adapter"
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="wifi_mode_error",
message="Error setting WiFi mode",
details=str(e)
)
)
async def get_saved_networks(self) -> PM3ServiceResult:
"""Get list of saved networks.
Returns:
PM3ServiceResult with list of saved networks
"""
try:
networks = await self.wifi_manager.get_saved_networks()
return PM3ServiceResult(
success=True,
data={
"networks": networks,
"count": len(networks)
}
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="saved_networks_error",
message="Failed to get saved networks",
details=str(e)
)
)
async def forget_network(self, ssid: str) -> PM3ServiceResult:
"""Forget a saved network.
Args:
ssid: SSID of network to forget
Returns:
PM3ServiceResult indicating success/failure
"""
try:
success = await self.wifi_manager.forget_network(ssid)
if success:
return PM3ServiceResult(
success=True,
data={
"message": f"Network '{ssid}' forgotten",
"ssid": ssid
}
)
else:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="network_not_found",
message=f"Network '{ssid}' not found in saved networks"
)
)
except Exception as e:
return PM3ServiceResult(
success=False,
error=PM3ServiceError(
code="forget_network_error",
message=f"Error forgetting network '{ssid}'",
details=str(e)
)
)

View File

@@ -0,0 +1,6 @@
"""WebSocket module for real-time notifications."""
from .manager import ws_manager, ConnectionManager
from .routes import router
from . import notifications
__all__ = ["ws_manager", "ConnectionManager", "router", "notifications"]

View File

@@ -0,0 +1,71 @@
"""WebSocket connection manager for real-time notifications."""
import asyncio
import json
from datetime import datetime, timezone
from typing import Set
from fastapi import WebSocket
class ConnectionManager:
"""Manages WebSocket connections and broadcasts."""
def __init__(self):
self._connections: Set[WebSocket] = set()
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket) -> None:
"""Accept and track a new WebSocket connection."""
await websocket.accept()
async with self._lock:
self._connections.add(websocket)
# Send initial connected event
await self._send_to_client(websocket, {
"type": "event",
"event": "connected",
"data": {"message": "Connected to Dangerous Pi event stream"},
"timestamp": datetime.now(timezone.utc).isoformat()
})
async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a disconnected WebSocket."""
async with self._lock:
self._connections.discard(websocket)
async def broadcast(self, event_type: str, data: dict) -> None:
"""Broadcast an event to all connected clients."""
message = {
"type": "event",
"event": event_type,
"data": data,
"timestamp": datetime.now(timezone.utc).isoformat()
}
async with self._lock:
dead_connections: Set[WebSocket] = set()
for connection in self._connections:
try:
await asyncio.wait_for(
self._send_to_client(connection, message),
timeout=5.0
)
except asyncio.TimeoutError:
dead_connections.add(connection)
except Exception:
dead_connections.add(connection)
# Remove dead connections
self._connections -= dead_connections
async def _send_to_client(self, websocket: WebSocket, message: dict) -> None:
"""Send a message to a specific client."""
await websocket.send_json(message)
@property
def connection_count(self) -> int:
"""Return number of active connections."""
return len(self._connections)
# Global singleton instance
ws_manager = ConnectionManager()

View File

@@ -0,0 +1,106 @@
"""Helper functions for sending WebSocket notifications."""
from .manager import ws_manager
# System Stats
async def notify_system_stats(
cpu_percent: float,
cpu_per_core: list,
load_average: list,
memory_percent: float,
temperature: float | None
) -> None:
"""Notify clients of system stats update."""
await ws_manager.broadcast("system_stats", {
"cpu_percent": cpu_percent,
"cpu_per_core": cpu_per_core,
"load_average": load_average,
"memory_percent": memory_percent,
"temperature": temperature
})
# PM3 Notifications
async def notify_pm3_status(connected: bool, message: str) -> None:
"""Notify clients of PM3 status changes."""
await ws_manager.broadcast("pm3_status", {
"connected": connected,
"message": message
})
async def notify_pm3_devices(devices: list) -> None:
"""Notify clients of PM3 device list changes (connect/disconnect)."""
await ws_manager.broadcast("pm3_devices", {
"devices": devices,
"count": len(devices)
})
# UPS Notifications
async def notify_ups_battery(percentage: float, voltage: float) -> None:
"""Notify clients of UPS battery status."""
await ws_manager.broadcast("ups_battery", {
"percentage": percentage,
"voltage": voltage
})
async def notify_ups_warning(percentage: float, threshold: float) -> None:
"""Notify clients of low battery warning."""
await ws_manager.broadcast("ups_warning", {
"percentage": percentage,
"threshold": threshold,
"message": f"Battery low: {percentage}%"
})
async def notify_ups_critical(percentage: float) -> None:
"""Notify clients of critical battery level."""
await ws_manager.broadcast("ups_critical", {
"percentage": percentage,
"message": f"Battery critical: {percentage}%"
})
async def notify_ups_shutdown(delay: int, percentage: float) -> None:
"""Notify clients that shutdown has been initiated."""
await ws_manager.broadcast("ups_shutdown", {
"delay": delay,
"percentage": percentage,
"message": f"Shutdown initiated due to low battery ({percentage}%)"
})
async def notify_ups_config_required(model: str, message: str, hint: str) -> None:
"""Notify clients that UPS configuration is required."""
await ws_manager.broadcast("ups_config_required", {
"model": model,
"message": message,
"hint": hint
})
# Update Notifications
async def notify_update_available(version: str, url: str) -> None:
"""Notify clients that an update is available."""
await ws_manager.broadcast("update_available", {
"version": version,
"url": url
})
async def notify_update_progress(progress: float, status: str) -> None:
"""Notify clients of update download progress."""
await ws_manager.broadcast("update_downloading", {
"progress": progress,
"status": status
})
async def notify_backup_complete(backup_path: str, size_bytes: int) -> None:
"""Notify clients that backup is complete."""
await ws_manager.broadcast("backup_complete", {
"path": backup_path,
"size": size_bytes
})

View File

@@ -0,0 +1,35 @@
"""WebSocket endpoint routes."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from .manager import ws_manager
router = APIRouter()
@router.websocket("/events")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time event streaming.
Events include:
- connected: Initial connection confirmation
- system_stats: CPU, memory, temperature updates (every 5s)
- pm3_devices: Device list on connect/disconnect
- pm3_status: PM3 connection status changes
- ups_battery: Battery level updates
- ups_warning: Low battery warning
- ups_critical: Critical battery level
- ups_shutdown: Shutdown initiated
- ups_config_required: UPS needs configuration
- update_available: New version available
- update_downloading: Download progress
"""
await ws_manager.connect(websocket)
try:
while True:
# Keep connection alive by waiting for messages or disconnect
# Client doesn't send data, but we need to detect disconnection
try:
await websocket.receive_text()
except WebSocketDisconnect:
break
finally:
await ws_manager.disconnect(websocket)

View File

@@ -0,0 +1 @@
"""Background workers."""

View File

@@ -0,0 +1,352 @@
"""Proxmark3 worker for executing commands."""
import asyncio
from dataclasses import dataclass
from typing import Optional
from pathlib import Path
import sys
import os
from .. import config
@dataclass
class PM3CommandResult:
"""Result from a PM3 command execution."""
success: bool
output: str
error: Optional[str] = None
class PM3Worker:
"""Worker for executing Proxmark3 commands using the built-in pm3 module."""
def __init__(self, device_path: str = None):
"""Initialize the PM3 worker.
Args:
device_path: Path to PM3 device (default: from config)
"""
self.device_path = device_path or config.PM3_DEVICE
self._device = None
self._connected = False
self._lock = asyncio.Lock()
self._pm3_module = None
async def _import_pm3(self):
"""Import the pm3 module (lazy loading)."""
if self._pm3_module is None:
try:
# Setup Python path for pm3 module
# The pm3 module is in proxmark3/client/pyscripts
# The _pm3.so is in proxmark3/client/experimental_lib/example_py
# Try multiple possible locations for pyscripts
# The pm3 module is in proxmark3/client/pyscripts
possible_pyscripts = [
os.path.expanduser("~/.pm3/proxmark3/client/pyscripts"), # Pi install location
"/home/dt/.pm3/proxmark3/client/pyscripts", # Pi install (explicit path)
os.path.expanduser("~/.pm3/client/pyscripts"), # Alternative structure
"/usr/local/share/proxmark3/pyscripts", # System install
"/usr/share/proxmark3/pyscripts", # System install alt
os.path.join(os.path.dirname(__file__), "../../../.pm3-test/proxmark3/client/pyscripts"), # Dev build
]
pm3_pyscripts = None
for path in possible_pyscripts:
pm3_py = os.path.join(path, "pm3.py")
if os.path.exists(pm3_py):
pm3_pyscripts = path
break
if not pm3_pyscripts:
raise ImportError(f"Could not find pm3.py in any of: {possible_pyscripts}")
# experimental_lib is at the same level as pyscripts (client/experimental_lib)
pm3_lib = os.path.join(os.path.dirname(pm3_pyscripts), "experimental_lib/example_py")
# Add to Python path if not already there
if pm3_pyscripts not in sys.path:
sys.path.insert(0, pm3_pyscripts)
if pm3_lib not in sys.path:
sys.path.insert(0, pm3_lib)
# Import pm3 module
import pm3
self._pm3_module = pm3
except ImportError as e:
raise ImportError(
"pm3 module not found. Ensure Proxmark3 client is installed with Python support. "
f"Error: {e}"
)
return self._pm3_module
async def _connect_internal(self):
"""Internal connect without locking (caller must hold lock)."""
if self._connected:
return
try:
pm3 = await self._import_pm3()
# Run blocking pm3.pm3() constructor in executor
loop = asyncio.get_event_loop()
self._device = await loop.run_in_executor(
None,
pm3.pm3,
self.device_path
)
self._connected = True
except Exception as e:
raise ConnectionError(f"Failed to connect to PM3 at {self.device_path}: {e}")
async def connect(self):
"""Connect to the Proxmark3 device."""
async with self._lock:
await self._connect_internal()
async def disconnect(self):
"""Disconnect from the Proxmark3 device."""
async with self._lock:
if self._device:
try:
# Close the device connection
# Note: pm3 module may not have explicit close, connection closes when object is deleted
self._device = None
self._connected = False
except Exception as e:
print(f"Error disconnecting from PM3: {e}")
async def is_connected(self) -> bool:
"""Check if connected to PM3 device."""
if not self._connected:
# Try to check if device exists
return Path(self.device_path).exists()
return self._connected
async def execute_command(self, command: str) -> PM3CommandResult:
"""Execute a Proxmark3 command.
Args:
command: PM3 command to execute (e.g., "hw version")
Returns:
PM3CommandResult with success status, output, and optional error
"""
async with self._lock:
try:
# Ensure we're connected (use internal method since we hold the lock)
if not self._connected:
await self._connect_internal()
if not self._device:
return PM3CommandResult(
success=False,
output="",
error="Not connected to PM3 device"
)
# Execute command in executor (blocking call)
loop = asyncio.get_event_loop()
try:
# Helper function to run command and get output in same executor call
def run_command():
try:
self._device.console(command)
output = self._device.grabbed_output
return output
except Exception as e:
raise Exception(f"Error in run_command: {e}, device type: {type(self._device)}")
# Execute command and get output
output = await loop.run_in_executor(None, run_command)
return PM3CommandResult(
success=True,
output=str(output) if output else "",
error=None
)
except Exception as cmd_error:
return PM3CommandResult(
success=False,
output="",
error=f"Command execution failed: {cmd_error}"
)
except Exception as e:
return PM3CommandResult(
success=False,
output="",
error=str(e)
)
# Mock PM3 Worker for testing without actual hardware
class MockPM3Worker(PM3Worker):
"""Mock PM3 worker for testing without hardware."""
def __init__(self, device_path: str = None):
super().__init__(device_path)
self._mock_responses = {
"hw version": "Proxmark3 RFID instrument\n client: RRG/Iceman/master/v4.14831",
"hw status": "Device: PM3 GENERIC\nBootrom: master/v4.14831\nOS: master/v4.14831",
"hw tune": "Measuring antenna characteristics, please wait...\n# LF antenna: 50.00 V @ 125.00 kHz",
"hw led": "[+] LED control command executed",
}
async def connect(self):
"""Mock connect."""
self._connected = True
async def disconnect(self):
"""Mock disconnect."""
self._connected = False
async def is_connected(self) -> bool:
"""Mock connection check."""
return True
async def execute_command(self, command: str) -> PM3CommandResult:
"""Mock command execution."""
await asyncio.sleep(0.1) # Simulate command delay
# Return mock response if available
for cmd_prefix, response in self._mock_responses.items():
if command.startswith(cmd_prefix):
return PM3CommandResult(
success=True,
output=response,
error=None
)
# Default response for unknown commands
return PM3CommandResult(
success=True,
output=f"Mock response for: {command}",
error=None
)
class SubprocessPM3Worker(PM3Worker):
"""PM3 worker using subprocess to call pm3 CLI directly.
This is a fallback for when the Python SWIG bindings aren't working.
It executes pm3 CLI commands via subprocess which is more robust.
"""
def __init__(self, device_path: str = None):
super().__init__(device_path)
self._pm3_path = None
self._find_pm3_executable()
def _find_pm3_executable(self):
"""Find the pm3 executable."""
possible_paths = [
"/usr/local/bin/pm3",
os.path.expanduser("~/.pm3/proxmark3/client/proxmark3"),
"/home/dt/.pm3/proxmark3/client/proxmark3",
"/usr/bin/pm3",
]
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self._pm3_path = path
break
if not self._pm3_path:
# Try to find in PATH
import shutil
pm3_in_path = shutil.which("pm3") or shutil.which("proxmark3")
if pm3_in_path:
self._pm3_path = pm3_in_path
async def connect(self):
"""Check that pm3 executable exists and device is available."""
async with self._lock:
if self._connected:
return
if not self._pm3_path:
raise ConnectionError("pm3 executable not found")
if not Path(self.device_path).exists():
raise ConnectionError(f"PM3 device not found at {self.device_path}")
self._connected = True
async def disconnect(self):
"""Mark as disconnected."""
async with self._lock:
self._connected = False
async def is_connected(self) -> bool:
"""Check if device exists."""
return Path(self.device_path).exists() if self.device_path else False
async def execute_command(self, command: str) -> PM3CommandResult:
"""Execute a PM3 command via subprocess.
Args:
command: PM3 command to execute (e.g., "hw version")
Returns:
PM3CommandResult with success status, output, and optional error
"""
async with self._lock:
try:
if not self._pm3_path:
return PM3CommandResult(
success=False,
output="",
error="pm3 executable not found"
)
# Build command: pm3 -p /dev/ttyACM0 -c "command"
cmd = [
self._pm3_path,
"-p", self.device_path,
"-c", command
]
# Run subprocess
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=30.0 # 30 second timeout
)
except asyncio.TimeoutError:
process.kill()
return PM3CommandResult(
success=False,
output="",
error="Command timed out after 30 seconds"
)
output = stdout.decode("utf-8", errors="replace")
error_output = stderr.decode("utf-8", errors="replace")
if process.returncode == 0:
return PM3CommandResult(
success=True,
output=output,
error=None
)
else:
return PM3CommandResult(
success=False,
output=output,
error=error_output or f"Command failed with exit code {process.returncode}"
)
except Exception as e:
return PM3CommandResult(
success=False,
output="",
error=str(e)
)

View File

@@ -0,0 +1,223 @@
# Dangerous Pi Frontend
Modern, lightweight web interface for Proxmark3 management built with Remix.js.
## Features
- **Cyberpunk Theme** - Dark mode default with light mode support
- **Responsive Design** - Mobile-first, works on all screen sizes
- **Real-time Updates** - SSE integration for live status
- **Lightweight** - Optimized for Pi Zero 2 W (< 150KB bundle)
- **Progressive Enhancement** - Works without JavaScript
## Tech Stack
- **Framework**: Remix v2 (React Router)
- **Styling**: Vanilla CSS (no framework, ~15KB)
- **Build**: Vite
- **TypeScript**: Full type safety
## Development
### Prerequisites
- Node.js 18+
- npm or yarn
- Backend running on http://localhost:8000
### Install Dependencies
```bash
npm install
```
### Start Development Server
```bash
npm run dev
```
Frontend will be available at http://localhost:3000
The dev server proxies API requests to the backend (http://localhost:8000).
### Build for Production
```bash
npm run build
```
### Start Production Server
```bash
npm start
```
## Project Structure
```
app/
├── routes/ # Page routes
│ ├── _index.tsx # Dashboard (/)
│ ├── commands.tsx # PM3 Commands (/commands)
│ ├── settings.tsx # Settings (/settings)
│ └── logs.tsx # Command Logs (/logs)
├── root.tsx # Root layout
├── styles.css # Global styles
└── entry.*.tsx # Client/Server entry points
```
## Pages
### Dashboard (`/`)
- System status (CPU, memory, disk)
- PM3 connection status
- Quick actions
- Real-time SSE updates
### Commands (`/commands`)
- Execute PM3 commands
- Quick command buttons
- Terminal-style output
- Command history with ↑/↓ navigation
### Settings (`/settings`)
- General settings (auth, HTTPS)
- Wi-Fi configuration
- Advanced options (PM3 device, BLE)
- System actions (restart, shutdown)
### Logs (`/logs`)
- Command history table
- Export functionality (planned)
- System logs (planned)
## Design System
### Colors (Cyberpunk)
- **Primary**: Cyan (`#00ffff`)
- **Secondary**: Magenta (`#ff00ff`)
- **Accent**: Green (`#00ff88`)
- **Background**: Dark Blue (`#0a0e1a`)
### Typography
- **Sans**: System font stack (zero download)
- **Mono**: SF Mono, Monaco, Cascadia Code, etc.
### Components
- Buttons (primary, secondary, danger)
- Cards with hover effects
- Status badges with pulse animation
- Terminal-style code blocks
- Toast notifications
- Form inputs with focus states
## Theme System
Supports three modes:
- **dark** - Cyberpunk dark theme (default)
- **light** - Clean light theme
- **auto** - Follow system preference
Theme persisted in localStorage, toggled via header button.
## API Integration
### REST Endpoints
```typescript
// System
GET /api/health
GET /api/system/info
POST /api/system/session/create
GET /api/system/config
// PM3
GET /api/pm3/status
POST /api/pm3/command
GET /api/pm3/commands/history
```
### SSE Events
```typescript
GET /sse/events
// Events:
- connected
- pm3_status
- update_available
- backup_complete
- ups_battery
```
## Performance Optimization
### Targets
- First Contentful Paint: < 1.5s
- Time to Interactive: < 3s
- Bundle Size: < 150KB gzipped
### Techniques
- Server-side rendering (SSR)
- CSS-only animations
- System fonts (no web fonts)
- Code splitting by route
- Minimal dependencies
## Accessibility
- WCAG 2.1 AA compliant
- Keyboard navigation
- Focus indicators
- ARIA labels
- Screen reader tested
## Testing Checklist
- [ ] Works without JavaScript
- [ ] Mobile responsive (320px+)
- [ ] Dark/light mode toggle
- [ ] SSE connection
- [ ] Command execution
- [ ] Session management
- [ ] Keyboard shortcuts
- [ ] Touch targets (44x44px)
## Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Mobile browsers (iOS 14+, Android 10+)
## Deployment
### With Backend
The frontend should be built and served by the backend in production:
```bash
# Build frontend
cd app/frontend
npm run build
# Backend serves from build/client/
```
### Standalone (Development)
For development, run separately:
```bash
# Terminal 1: Backend
python -m app.backend.main
# Terminal 2: Frontend
cd app/frontend
npm run dev
```
## Contributing
See [UI_GUIDELINES.md](../../UI_GUIDELINES.md) for design principles and best practices.
## License
Part of the Dangerous Pi project. Built for [Dangerous Things](https://dangerousthings.com).

View File

@@ -0,0 +1,219 @@
import { Form } from "@remix-run/react";
import { useState } from "react";
interface ConnectDialogProps {
network: {
ssid: string;
encrypted: boolean;
signal_strength: number;
_scannedPassword?: string; // Pre-filled from QR scan
} | null;
onClose: () => void;
isSubmitting: boolean;
}
export default function ConnectDialog({ network, onClose, isSubmitting }: ConnectDialogProps) {
// Initialize password from QR scan if provided
const [password, setPassword] = useState(network?._scannedPassword || "");
const [showPassword, setShowPassword] = useState(false);
// Start in hidden mode if no network is provided (user wants to enter SSID manually)
const [hidden, setHidden] = useState(!network);
const [customSSID, setCustomSSID] = useState(network?.ssid || "");
if (!network && !hidden) return null;
const ssid = hidden ? customSSID : network?.ssid || "";
const needsPassword = hidden || (network?.encrypted ?? false);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.9)",
backdropFilter: "blur(8px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 200,
padding: "var(--space-4)",
}}
onClick={onClose}
>
<div
className="card"
style={{
maxWidth: "500px",
width: "100%",
margin: 0,
animation: "toast-in 250ms ease",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 className="card-title" style={{ marginBottom: "var(--space-4)" }}>
<span style={{ color: "var(--color-primary)" }}>📡</span>{" "}
Connect to WiFi
</h3>
<Form method="post">
<input type="hidden" name="_action" value="connect_network" />
{/* SSID Input (for hidden networks) */}
{!hidden ? (
<>
<div className="form-group">
<label className="label">Network</label>
<div
style={{
padding: "var(--space-3)",
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontWeight: 600 }}>{network?.ssid}</div>
<div style={{ fontSize: "0.75rem", color: "var(--color-text-muted)" }}>
{network?.encrypted ? "🔒 Secured" : "Unsecured"}
{" • "}
{network?.signal_strength} dBm
</div>
</div>
</div>
<input type="hidden" name="ssid" value={network?.ssid || ""} />
</div>
<div style={{ textAlign: "center", marginBottom: "var(--space-4)" }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: "0.75rem", padding: "var(--space-2) var(--space-3)" }}
onClick={() => setHidden(true)}
>
Connect to hidden network instead
</button>
</div>
</>
) : (
<>
<div className="form-group">
<label htmlFor="ssid" className="label">
Network Name (SSID)
</label>
<input
type="text"
id="ssid"
name="ssid"
className="input"
placeholder="Enter network name"
value={customSSID}
onChange={(e) => setCustomSSID(e.target.value)}
required
autoFocus
/>
</div>
<div style={{ textAlign: "center", marginBottom: "var(--space-4)" }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: "0.75rem", padding: "var(--space-2) var(--space-3)" }}
onClick={() => setHidden(false)}
>
Back to scanned networks
</button>
</div>
</>
)}
{/* Password Input */}
{needsPassword && (
<div className="form-group">
<label htmlFor="password" className="label">
Password
</label>
<div style={{ position: "relative" }}>
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
className="input"
placeholder="Enter network password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={needsPassword}
autoComplete="off"
style={{ paddingRight: "3rem" }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: "absolute",
right: "var(--space-3)",
top: "50%",
transform: "translateY(-50%)",
background: "transparent",
border: "none",
color: "var(--color-text-secondary)",
cursor: "pointer",
fontSize: "1.25rem",
}}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "👁" : "👁‍🗨"}
</button>
</div>
</div>
)}
{/* Hidden Network Checkbox */}
<input type="hidden" name="hidden" value={hidden ? "true" : "false"} />
{/* Actions */}
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-6)" }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={isSubmitting || (!ssid || (needsPassword && !password))}
>
{isSubmitting ? (
<>
<span className="spinner"></span>
<span>Connecting...</span>
</>
) : (
<>
<span></span>
<span>Connect</span>
</>
)}
</button>
</div>
</Form>
{needsPassword && (
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-4)", textAlign: "center" }}>
Your password will be securely stored
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,634 @@
import { useState, useEffect } from "react";
import { useWebSocketEvent } from "../hooks/useWebSocket";
interface FirmwareInfo {
bootrom_version: string;
os_version: string;
client_version: string;
compatible: boolean;
needs_upgrade: boolean;
needs_downgrade: boolean;
}
interface Device {
device_id: string;
device_path: string;
serial_number: string | null;
friendly_name: string | null;
usb_vid: string;
usb_pid: string;
status: "connected" | "disconnected" | "in_use" | "error" | "version_mismatch" | "flashing" | "disabled";
firmware_info: FirmwareInfo | null;
session_id: string | null;
last_seen: string;
}
interface FlashProgress {
device_id: string;
status: "starting" | "bootrom" | "fullimage" | "verifying" | "complete" | "error";
progress: number;
message: string;
}
interface DeviceSelectorProps {
selectedDeviceId: string | null;
onDeviceSelect: (deviceId: string) => void;
showIdentifyButton?: boolean;
autoSelectSingle?: boolean;
}
export default function DeviceSelector({
selectedDeviceId,
onDeviceSelect,
showIdentifyButton = true,
autoSelectSingle = true,
}: DeviceSelectorProps) {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [identifying, setIdentifying] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Flash-related state
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null);
const [showFlashConfirm, setShowFlashConfirm] = useState<string | null>(null);
const [flashing, setFlashing] = useState<string | null>(null);
const [flashError, setFlashError] = useState<string | null>(null);
// Subscribe to flash progress events
useWebSocketEvent("pm3_flash_progress", (data) => {
const progress = data as FlashProgress;
setFlashProgress(progress);
// Clear flashing state on complete or error
if (progress.status === "complete" || progress.status === "error") {
setFlashing(null);
if (progress.status === "error") {
setFlashError(progress.message);
}
// Clear progress after a delay
setTimeout(() => setFlashProgress(null), 5000);
}
});
// Fetch devices from API
const fetchDevices = async () => {
try {
const response = await fetch("/api/pm3/devices");
if (!response.ok) {
throw new Error(`Failed to fetch devices: ${response.statusText}`);
}
const data = await response.json();
// API returns {devices: [...]} directly (no success field)
const deviceList = data.devices || [];
setDevices(deviceList);
// Auto-select single device if enabled
if (autoSelectSingle && deviceList.length === 1 && !selectedDeviceId) {
const device = deviceList[0];
if (device.status === "connected") {
onDeviceSelect(device.device_id);
}
}
setError(null);
} catch (err) {
console.error("Error fetching devices:", err);
setError(err instanceof Error ? err.message : "Failed to fetch devices");
setDevices([]);
} finally {
setLoading(false);
}
};
// Fetch devices on mount and set up polling
useEffect(() => {
fetchDevices();
const interval = setInterval(fetchDevices, 5000); // Poll every 5 seconds
return () => clearInterval(interval);
}, []);
// Handle device identification (LED blinking)
const handleIdentify = async (deviceId: string) => {
setIdentifying(deviceId);
try {
const response = await fetch(`/api/pm3/devices/${deviceId}/identify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ duration: 2000 }),
});
if (!response.ok) {
throw new Error("Failed to identify device");
}
// Keep identifying state for duration of LED blink
setTimeout(() => setIdentifying(null), 2100);
} catch (err) {
console.error("Error identifying device:", err);
setIdentifying(null);
}
};
// Handle firmware flash
const handleFlash = async (deviceId: string) => {
setShowFlashConfirm(null);
setFlashing(deviceId);
setFlashError(null);
try {
const response = await fetch(`/api/pm3/devices/${deviceId}/flash`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Flash request failed");
}
// Flash started - progress will come via WebSocket
} catch (err) {
console.error("Error flashing device:", err);
setFlashing(null);
setFlashError(err instanceof Error ? err.message : "Flash failed");
}
};
// Get status color
const getStatusColor = (device: Device): string => {
switch (device.status) {
case "connected":
return "var(--color-success)";
case "in_use":
return "var(--color-warning)";
case "version_mismatch":
case "disabled":
return "var(--color-warning)";
case "error":
case "disconnected":
return "var(--color-error)";
case "flashing":
return "var(--color-info)";
default:
return "var(--color-border)";
}
};
// Get status text
const getStatusText = (device: Device): string => {
switch (device.status) {
case "connected":
return "Connected";
case "in_use":
return "In Use";
case "version_mismatch":
return "Version Mismatch";
case "disabled":
return "Disabled";
case "error":
return "Error";
case "disconnected":
return "Disconnected";
case "flashing":
return "Flashing";
default:
return "Unknown";
}
};
// Check if device is selectable
const isDeviceSelectable = (device: Device): boolean => {
return (
device.status === "connected" ||
(device.status === "in_use" && device.device_id === selectedDeviceId)
);
};
// Get display name for device
const getDeviceName = (device: Device): string => {
return device.friendly_name || device.device_path;
};
if (loading) {
return (
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices
</h3>
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
<span className="spinner"></span>
<p className="text-muted" style={{ marginTop: "var(--space-3)" }}>
Detecting devices...
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="card" style={{ borderColor: "var(--color-error)" }}>
<h3 className="card-title">
<span style={{ color: "var(--color-error)" }}></span> Device Detection Error
</h3>
<p className="text-muted">{error}</p>
<button
className="btn btn-secondary"
style={{ marginTop: "var(--space-3)" }}
onClick={fetchDevices}
>
Retry
</button>
</div>
);
}
if (devices.length === 0) {
return (
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices
</h3>
<div style={{ textAlign: "center", padding: "var(--space-6)" }}>
<div style={{ fontSize: "3rem", marginBottom: "var(--space-3)", opacity: 0.5 }}>
🔌
</div>
<p className="text-secondary" style={{ marginBottom: "var(--space-2)" }}>
No Proxmark3 devices detected
</p>
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
Connect a Proxmark3 device via USB to get started
</p>
</div>
</div>
);
}
const confirmDevice = showFlashConfirm ? devices.find(d => d.device_id === showFlashConfirm) : null;
return (
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}>📡</span> Proxmark3 Devices ({devices.length})
</h3>
{/* Flash Error Alert */}
{flashError && (
<div
style={{
marginBottom: "var(--space-3)",
padding: "var(--space-3)",
background: "rgba(239, 68, 68, 0.1)",
borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>
<strong>Flash Error:</strong> {flashError}
</span>
<button
className="btn btn-secondary"
style={{ padding: "var(--space-1) var(--space-2)", fontSize: "0.75rem" }}
onClick={() => setFlashError(null)}
>
Dismiss
</button>
</div>
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
{devices.map((device) => {
const isSelected = selectedDeviceId === device.device_id;
const isSelectable = isDeviceSelectable(device);
const statusColor = getStatusColor(device);
const isFlashing = device.status === "flashing" || flashing === device.device_id;
const deviceFlashProgress = flashProgress?.device_id === device.device_id ? flashProgress : null;
return (
<div
key={device.device_id}
style={{
padding: "var(--space-3)",
border: "2px solid",
borderColor: isSelected ? "var(--color-primary)" : statusColor,
borderRadius: "var(--radius)",
cursor: isSelectable && !isFlashing ? "pointer" : "not-allowed",
opacity: isSelectable || isFlashing ? 1 : 0.6,
transition: "all 150ms ease",
background: isSelected ? "rgba(37, 99, 235, 0.05)" : "transparent",
position: "relative",
}}
onClick={() => {
if (isSelectable && !isSelected && !isFlashing) {
onDeviceSelect(device.device_id);
}
}}
>
{/* Flash Progress Overlay */}
{isFlashing && deviceFlashProgress && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.8)",
borderRadius: "var(--radius)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
zIndex: 10,
padding: "var(--space-4)",
}}
>
<div style={{ color: "var(--color-info)", fontSize: "2rem", marginBottom: "var(--space-2)" }}>
{deviceFlashProgress.status === "complete" ? "✓" : deviceFlashProgress.status === "error" ? "✗" : "⚡"}
</div>
<div style={{ color: "white", fontWeight: 600, marginBottom: "var(--space-2)" }}>
{deviceFlashProgress.status === "complete" ? "Flash Complete" :
deviceFlashProgress.status === "error" ? "Flash Failed" : "Flashing Firmware..."}
</div>
<div style={{ width: "100%", marginBottom: "var(--space-2)" }}>
<div
style={{
height: "8px",
background: "rgba(255,255,255,0.2)",
borderRadius: "4px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${deviceFlashProgress.progress}%`,
background: deviceFlashProgress.status === "error" ? "var(--color-error)" :
deviceFlashProgress.status === "complete" ? "var(--color-success)" : "var(--color-info)",
transition: "width 300ms ease",
}}
/>
</div>
</div>
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "0.875rem", textAlign: "center" }}>
{deviceFlashProgress.message}
</div>
<div style={{ color: "rgba(255,255,255,0.5)", fontSize: "0.75rem", marginTop: "var(--space-1)" }}>
{deviceFlashProgress.progress}%
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "var(--space-3)" }}>
{/* Device Info */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Device Name */}
<div style={{ fontWeight: 600, fontSize: "1.1rem", marginBottom: "var(--space-1)" }}>
{isSelected && (
<span style={{ color: "var(--color-primary)", marginRight: "var(--space-2)" }}>
</span>
)}
{getDeviceName(device)}
</div>
{/* Device Path & Serial */}
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted)", marginBottom: "var(--space-2)" }}>
<span style={{ fontFamily: "var(--font-mono)" }}>{device.device_path}</span>
{device.serial_number && (
<>
{" • "}
<span>SN: {device.serial_number}</span>
</>
)}
</div>
{/* Status & Firmware Version */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-2)", alignItems: "center" }}>
<span
className={`badge ${
device.status === "connected"
? "badge-success"
: device.status === "error" || device.status === "disconnected"
? "badge-error"
: device.status === "flashing"
? "badge-info"
: "badge-warning"
}`}
>
<span aria-hidden="true"></span>
{getStatusText(device)}
</span>
{device.firmware_info && (
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
{device.firmware_info.os_version}
</span>
)}
{device.status === "in_use" && device.device_id !== selectedDeviceId && (
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
🔒 Session Active
</span>
)}
</div>
{/* Version Mismatch Warning with Flash Button */}
{device.firmware_info && !device.firmware_info.compatible && device.status !== "flashing" && (
<div
style={{
marginTop: "var(--space-2)",
padding: "var(--space-2)",
background: "rgba(217, 119, 6, 0.1)",
borderLeft: "3px solid var(--color-warning)",
borderRadius: "var(--radius)",
fontSize: "0.75rem",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "var(--space-2)" }}>
<div>
<div style={{ fontWeight: 600, marginBottom: "var(--space-1)" }}>
Firmware Version Mismatch
</div>
<div className="text-muted">
Device: {device.firmware_info.os_version}
Expected: {device.firmware_info.client_version}
</div>
</div>
<button
className="btn btn-warning"
style={{
fontSize: "0.75rem",
padding: "var(--space-1) var(--space-2)",
flexShrink: 0,
}}
onClick={(e) => {
e.stopPropagation();
setShowFlashConfirm(device.device_id);
}}
disabled={flashing !== null}
>
Flash Firmware
</button>
</div>
</div>
)}
</div>
{/* Identify Button */}
{showIdentifyButton && device.status === "connected" && !isFlashing && (
<button
className="btn btn-secondary"
style={{
fontSize: "0.875rem",
padding: "var(--space-2) var(--space-3)",
minWidth: "44px",
flexShrink: 0,
}}
onClick={(e) => {
e.stopPropagation();
handleIdentify(device.device_id);
}}
disabled={identifying === device.device_id}
aria-label="Identify device with LED blink"
>
{identifying === device.device_id ? (
<>
<span className="spinner"></span>
<span>Blinking...</span>
</>
) : (
<>
<span>💡</span>
<span>Identify</span>
</>
)}
</button>
)}
</div>
</div>
);
})}
</div>
{/* Selection Info */}
{selectedDeviceId && (
<div
style={{
marginTop: "var(--space-4)",
padding: "var(--space-3)",
background: "var(--color-bg)",
borderRadius: "var(--radius)",
fontSize: "0.875rem",
}}
>
<span style={{ color: "var(--color-success)" }}></span> Selected device will be used for all commands
</div>
)}
{/* Flash Confirmation Dialog */}
{showFlashConfirm && confirmDevice && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.75)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
}}
onClick={() => setShowFlashConfirm(null)}
>
<div
style={{
background: "var(--color-card)",
borderRadius: "var(--radius)",
padding: "var(--space-6)",
maxWidth: "450px",
width: "90%",
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, marginBottom: "var(--space-4)" }}>
<span style={{ color: "var(--color-warning)" }}></span> Flash Firmware
</h3>
<p style={{ marginBottom: "var(--space-4)" }}>
You are about to flash firmware to this device. This process will:
</p>
<ul style={{ marginBottom: "var(--space-4)", paddingLeft: "var(--space-4)" }}>
<li>Update bootrom and fullimage</li>
<li>Make the device temporarily unavailable</li>
<li>Take approximately 1-2 minutes</li>
</ul>
<div
style={{
background: "var(--color-bg)",
borderRadius: "var(--radius)",
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
fontSize: "0.875rem",
}}
>
<div style={{ marginBottom: "var(--space-2)" }}>
<strong>Device:</strong> {getDeviceName(confirmDevice)}
</div>
<div style={{ marginBottom: "var(--space-2)" }}>
<strong>Path:</strong> {confirmDevice.device_path}
</div>
{confirmDevice.firmware_info && (
<>
<div style={{ marginBottom: "var(--space-2)" }}>
<strong>Current:</strong> {confirmDevice.firmware_info.os_version}
</div>
<div>
<strong>Target:</strong> {confirmDevice.firmware_info.client_version}
</div>
</>
)}
</div>
<div
style={{
background: "rgba(239, 68, 68, 0.1)",
borderLeft: "3px solid var(--color-error)",
borderRadius: "var(--radius)",
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
fontSize: "0.875rem",
}}
>
<strong>Warning:</strong> Do not disconnect the device during flashing.
Interruption may require recovery mode.
</div>
<div style={{ display: "flex", gap: "var(--space-3)", justifyContent: "flex-end" }}>
<button
className="btn btn-secondary"
onClick={() => setShowFlashConfirm(null)}
>
Cancel
</button>
<button
className="btn btn-warning"
onClick={() => handleFlash(showFlashConfirm)}
>
Flash Firmware
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
/**
* HeaderWidgets - Display status widgets in the header area
*
* Shows notifications from:
* - System managers (UPS, PM3, Updates)
* - Enabled plugins
*
* Supports:
* - Multiple severity levels (info, warning, error, success)
* - Dismissible widgets
* - Action buttons
* - Auto-expiry
* - Real-time updates via WebSocket
*/
import { useState, useEffect, useCallback } from "react";
import { Link } from "@remix-run/react";
import { useWebSocketEvent } from "~/hooks/useWebSocket";
interface HeaderWidget {
id: string;
source: string;
severity: "info" | "warning" | "error" | "success";
message: string;
dismissible: boolean;
icon?: string;
action_label?: string;
action_url?: string;
created_at: string;
expires_at?: string;
}
interface HeaderWidgetsProps {
apiBase?: string;
}
export function HeaderWidgets({ apiBase = "/api" }: HeaderWidgetsProps) {
const [widgets, setWidgets] = useState<HeaderWidget[]>([]);
const [loading, setLoading] = useState(true);
// Fetch widgets from API
const fetchWidgets = useCallback(async () => {
try {
const response = await fetch(`${apiBase}/system/widgets`);
if (response.ok) {
const data: HeaderWidget[] = await response.json();
setWidgets(data);
}
} catch (error) {
console.error("Failed to load widgets:", error);
} finally {
setLoading(false);
}
}, [apiBase]);
// WebSocket event subscriptions for real-time updates
useWebSocketEvent("widget_added", () => {
// Refresh widgets when a new one is added
fetchWidgets();
});
useWebSocketEvent("widget_removed", (data) => {
// Remove widget from local state
const widgetId = data.widget_id as string;
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
});
// Initial fetch and periodic refresh (30s fallback)
useEffect(() => {
fetchWidgets();
const interval = setInterval(fetchWidgets, 30000);
return () => clearInterval(interval);
}, [fetchWidgets]);
// Dismiss a widget
const dismissWidget = async (widgetId: string) => {
try {
const response = await fetch(`${apiBase}/system/widgets/${encodeURIComponent(widgetId)}/dismiss`, {
method: "POST",
});
if (response.ok) {
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
}
} catch (error) {
console.error("Failed to dismiss widget:", error);
}
};
// Don't render anything if loading or no widgets
if (loading || widgets.length === 0) {
return null;
}
return (
<div className="header-widgets" role="region" aria-label="Notifications">
{widgets.map((widget) => (
<div
key={widget.id}
className={`widget widget-${widget.severity}`}
role="alert"
aria-live={widget.severity === "error" ? "assertive" : "polite"}
>
{widget.icon && (
<span className="widget-icon" aria-hidden="true">
{widget.icon}
</span>
)}
<span className="widget-message">{widget.message}</span>
{widget.action_url && widget.action_label && (
<Link to={widget.action_url} className="widget-action">
{widget.action_label}
</Link>
)}
{widget.dismissible && (
<button
onClick={() => dismissWidget(widget.id)}
className="widget-dismiss"
aria-label={`Dismiss: ${widget.message}`}
title="Dismiss"
>
<DismissIcon />
</button>
)}
</div>
))}
</div>
);
}
function DismissIcon() {
return (
<svg
className="dismiss-icon"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M2 2L10 10M10 2L2 10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export default HeaderWidgets;

View File

@@ -0,0 +1,243 @@
/**
* PowerWidget - Header battery/power status indicator
*
* Displays UPS/battery status in the header with:
* - Battery icon with fill level
* - Percentage display
* - Charging indicator
* - Real-time updates via WebSocket
*/
import { useState, useEffect, useCallback } from "react";
import { useWebSocketEvent } from "~/hooks/useWebSocket";
interface UPSStatus {
battery_percentage: number;
voltage: number;
current: number;
power_source: "ac" | "battery" | "unknown";
battery_status: "charging" | "discharging" | "full" | "critical" | "unknown";
time_remaining: number | null;
temperature: number | null;
last_updated: string | null;
is_available: boolean;
error_message: string | null;
}
interface PowerWidgetProps {
apiBase?: string;
}
export function PowerWidget({ apiBase = "/api" }: PowerWidgetProps) {
const [status, setStatus] = useState<UPSStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch UPS status from API
const fetchStatus = useCallback(async () => {
try {
const response = await fetch(`${apiBase}/system/ups/status`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data: UPSStatus = await response.json();
setStatus(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch");
} finally {
setLoading(false);
}
}, [apiBase]);
// WebSocket event subscriptions for real-time updates
useWebSocketEvent("ups_battery", (data) => {
setStatus((prev) => prev ? {
...prev,
battery_percentage: data.percentage as number,
voltage: data.voltage as number,
} : prev);
});
useWebSocketEvent("ups_warning", () => {
// Trigger a full refresh on warnings
fetchStatus();
});
useWebSocketEvent("ups_critical", () => {
fetchStatus();
});
// Initial fetch and fallback polling (120s since WebSocket is primary)
useEffect(() => {
fetchStatus();
const pollInterval = setInterval(fetchStatus, 120000);
return () => clearInterval(pollInterval);
}, [fetchStatus]);
// Don't render if UPS is not available
if (!loading && (!status || !status.is_available)) {
return null;
}
// Loading state
if (loading) {
return (
<div className="power-widget power-widget--loading" title="Loading power status...">
<div className="power-widget__icon">
<BatteryIcon percentage={50} />
</div>
</div>
);
}
const percentage = Math.round(status?.battery_percentage ?? 0);
const isCharging = status?.power_source === "ac" || status?.battery_status === "charging";
const isCritical = percentage <= 10;
const isLow = percentage <= 20;
const isFull = percentage >= 95 && isCharging;
// Determine status color
let statusClass = "power-widget--normal";
if (isCritical) {
statusClass = "power-widget--critical";
} else if (isLow) {
statusClass = "power-widget--low";
} else if (isFull) {
statusClass = "power-widget--full";
} else if (isCharging) {
statusClass = "power-widget--charging";
}
// Build tooltip
const tooltipParts = [
`Battery: ${percentage}%`,
`Source: ${status?.power_source === "ac" ? "AC Power" : "Battery"}`,
];
if (status?.voltage && status.voltage > 0) {
// Voltage comes in mV from API, convert to V for display
tooltipParts.push(`Voltage: ${(status.voltage / 1000).toFixed(2)}V`);
}
if (status?.current !== undefined && status?.current !== null) {
// Current in Amps - positive = charging, negative = discharging
const absCurrentMa = Math.abs(status.current * 1000).toFixed(0);
const direction = status.current >= 0 ? "charging" : "draw";
tooltipParts.push(`Current: ${absCurrentMa}mA ${direction}`);
}
if (status?.time_remaining) {
// Format time remaining nicely
const mins = status.time_remaining;
if (mins < 60) {
tooltipParts.push(`Est. runtime: ${mins} min`);
} else {
const hours = Math.floor(mins / 60);
const remainMins = mins % 60;
tooltipParts.push(`Est. runtime: ${hours}h ${remainMins}m`);
}
}
const tooltip = tooltipParts.join("\n");
return (
<div
className={`power-widget ${statusClass}`}
title={tooltip}
role="status"
aria-label={`Battery at ${percentage}%${isCharging ? ", plugged in" : ", on battery"}`}
>
<div className="power-widget__icon">
<BatteryIcon percentage={percentage} isCharging={isCharging} />
</div>
<span className="power-widget__percentage">{percentage}%</span>
{isCharging && (
<span className="power-widget__plug-indicator" aria-label="Plugged in" title="AC Power">
<PlugIcon />
</span>
)}
</div>
);
}
interface BatteryIconProps {
percentage: number;
isCharging?: boolean;
}
function BatteryIcon({ percentage, isCharging = false }: BatteryIconProps) {
// Calculate fill width (0-100%)
const fillWidth = Math.max(0, Math.min(100, percentage));
return (
<svg
className="battery-icon"
viewBox="0 0 24 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Battery body outline */}
<rect
x="0.5"
y="0.5"
width="20"
height="13"
rx="2"
stroke="currentColor"
strokeWidth="1"
fill="none"
/>
{/* Battery terminal */}
<rect
x="21"
y="4"
width="3"
height="6"
rx="1"
fill="currentColor"
/>
{/* Battery fill */}
<rect
className="battery-icon__fill"
x="2"
y="2"
width={Math.max(0, (fillWidth / 100) * 17)}
height="10"
rx="1"
fill="currentColor"
/>
{/* Charging bolt */}
{isCharging && (
<path
className="battery-icon__bolt"
d="M12 1L8 7H11L9 13L14 6H11L12 1Z"
fill="var(--color-bg)"
stroke="currentColor"
strokeWidth="0.5"
/>
)}
</svg>
);
}
function PlugIcon() {
return (
<svg
className="plug-icon"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Plug prongs */}
<rect x="3" y="0" width="2" height="4" rx="0.5" fill="currentColor" />
<rect x="7" y="0" width="2" height="4" rx="0.5" fill="currentColor" />
{/* Plug body */}
<rect x="2" y="3" width="8" height="5" rx="1" fill="currentColor" />
{/* Cord */}
<rect x="5" y="8" width="2" height="4" rx="0.5" fill="currentColor" />
</svg>
);
}
export default PowerWidget;

View File

@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
interface WifiCredentials {
ssid: string;
password: string;
security: string;
hidden: boolean;
}
interface QRScannerProps {
onScan: (credentials: WifiCredentials) => void;
onClose: () => void;
onError?: (error: string) => void;
}
/**
* Parse WiFi QR code string
* Format: WIFI:T:WPA;S:NetworkName;P:Password;H:true;;
*/
function parseWifiQR(text: string): WifiCredentials | null {
if (!text.startsWith("WIFI:")) {
return null;
}
const result: WifiCredentials = {
ssid: "",
password: "",
security: "WPA",
hidden: false,
};
// Remove WIFI: prefix and trailing ;;
const data = text.slice(5).replace(/;;$/, "");
// Parse key:value pairs separated by ;
const parts = data.split(";");
for (const part of parts) {
const colonIdx = part.indexOf(":");
if (colonIdx === -1) continue;
const key = part.slice(0, colonIdx).toUpperCase();
const value = part.slice(colonIdx + 1);
switch (key) {
case "S":
result.ssid = value;
break;
case "P":
result.password = value;
break;
case "T":
result.security = value.toUpperCase();
break;
case "H":
result.hidden = value.toLowerCase() === "true";
break;
}
}
// Unescape special characters
result.ssid = result.ssid.replace(/\\;/g, ";").replace(/\\:/g, ":").replace(/\\\\/g, "\\");
result.password = result.password.replace(/\\;/g, ";").replace(/\\:/g, ":").replace(/\\\\/g, "\\");
return result.ssid ? result : null;
}
export default function QRScanner({ onScan, onClose, onError }: QRScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cameraReady, setCameraReady] = useState(false);
const scannerRef = useRef<any>(null);
const streamRef = useRef<MediaStream | null>(null);
const isRunningRef = useRef(false);
// Safe stop function that checks scanner state
const safeStopScanner = async () => {
if (scannerRef.current && isRunningRef.current) {
try {
await scannerRef.current.stop();
isRunningRef.current = false;
} catch (err) {
// Ignore stop errors - scanner may already be stopped
console.debug("Scanner stop (already stopped):", err);
}
}
};
useEffect(() => {
let mounted = true;
let animationFrameId: number;
const startScanner = async () => {
try {
// Import html5-qrcode dynamically (client-side only)
const { Html5Qrcode } = await import("html5-qrcode");
if (!mounted) return;
const scanner = new Html5Qrcode("qr-reader");
scannerRef.current = scanner;
await scanner.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
(decodedText) => {
const credentials = parseWifiQR(decodedText);
if (credentials) {
isRunningRef.current = false;
scanner.stop().catch(console.error);
onScan(credentials);
} else {
setError("Not a valid WiFi QR code");
setTimeout(() => setError(null), 2000);
}
},
() => {} // Ignore scan failures
);
if (mounted) {
isRunningRef.current = true;
setScanning(true);
setCameraReady(true);
}
} catch (err: any) {
console.error("QR Scanner error:", err);
const errorMessage = err.message || "Failed to access camera";
setError(errorMessage);
onError?.(errorMessage);
}
};
startScanner();
return () => {
mounted = false;
safeStopScanner();
};
}, [onScan, onError]);
const handleClose = () => {
safeStopScanner();
onClose();
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(10, 14, 26, 0.95)",
backdropFilter: "blur(8px)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
zIndex: 250,
padding: "var(--space-4)",
}}
>
<div
style={{
maxWidth: "400px",
width: "100%",
textAlign: "center",
}}
>
<h3 style={{ marginBottom: "var(--space-4)", color: "var(--color-text)" }}>
<span style={{ color: "var(--color-primary)" }}>📷</span> Scan WiFi QR Code
</h3>
<p style={{
fontSize: "0.875rem",
color: "var(--color-text-muted)",
marginBottom: "var(--space-4)"
}}>
Point camera at a WiFi QR code to connect automatically
</p>
{/* QR Scanner Container */}
<div
id="qr-reader"
style={{
width: "100%",
maxWidth: "350px",
margin: "0 auto var(--space-4)",
borderRadius: "var(--radius)",
overflow: "hidden",
background: "#000",
minHeight: "300px",
}}
/>
{/* Status Messages */}
{error && (
<div
style={{
padding: "var(--space-3)",
background: "rgba(255, 107, 107, 0.1)",
border: "1px solid var(--color-error)",
borderRadius: "var(--radius)",
marginBottom: "var(--space-4)",
color: "var(--color-error)",
fontSize: "0.875rem",
}}
>
{error}
</div>
)}
{!cameraReady && !error && (
<div
style={{
padding: "var(--space-3)",
marginBottom: "var(--space-4)",
color: "var(--color-text-muted)",
fontSize: "0.875rem",
}}
>
<span className="spinner" style={{ marginRight: "var(--space-2)" }}></span>
Initializing camera...
</div>
)}
{/* Hint */}
<div
style={{
fontSize: "0.75rem",
color: "var(--color-text-muted)",
marginBottom: "var(--space-4)",
padding: "var(--space-3)",
background: "var(--color-bg)",
borderRadius: "var(--radius)",
}}
>
<strong>Tip:</strong> Most routers have a WiFi QR code on a sticker.
You can also generate one at{" "}
<a
href="https://qifi.org"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--color-primary)" }}
>
qifi.org
</a>
</div>
{/* Close Button */}
<button
type="button"
className="btn btn-secondary"
onClick={handleClose}
style={{ width: "100%" }}
>
Cancel
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

View File

@@ -0,0 +1,22 @@
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
let markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + markup, {
headers: responseHeaders,
status: responseStatusCode,
});
}

View File

@@ -0,0 +1,279 @@
/**
* WebSocket hook for real-time event subscriptions.
*
* Features:
* - Single shared connection per browser tab
* - Exponential backoff reconnection (1s -> 2s -> 4s -> ... -> 30s max)
* - Component-level event subscriptions
* - Connection state management
*/
import { useEffect, useRef, useState } from "react";
// Connection states
export type ConnectionState = "connecting" | "connected" | "disconnected";
// Event message format from backend
interface WebSocketMessage {
type: "event";
event: string;
data: Record<string, unknown>;
timestamp: string;
}
// Event handler type
type EventHandler = (data: Record<string, unknown>) => void;
// Singleton WebSocket manager
class WebSocketManager {
private static instance: WebSocketManager | null = null;
private ws: WebSocket | null = null;
private subscribers: Map<string, Set<EventHandler>> = new Map();
private stateListeners: Set<(state: ConnectionState) => void> = new Set();
private reconnectAttempts = 0;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private state: ConnectionState = "disconnected";
// Backoff configuration
private readonly INITIAL_DELAY = 1000; // 1 second
private readonly MAX_DELAY = 30000; // 30 seconds
private readonly BACKOFF_MULTIPLIER = 2;
private constructor() {}
static getInstance(): WebSocketManager {
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager();
}
return WebSocketManager.instance;
}
private getWebSocketUrl(): string {
if (typeof window === "undefined") {
return "ws://localhost:8000/ws/events";
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.hostname;
const port = window.location.port || (protocol === "wss:" ? "443" : "80");
return `${protocol}//${host}:${port}/ws/events`;
}
connect(): void {
if (
this.ws?.readyState === WebSocket.OPEN ||
this.ws?.readyState === WebSocket.CONNECTING
) {
return;
}
this.setState("connecting");
const url = this.getWebSocketUrl();
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log("[WS] Connected to", url);
this.reconnectAttempts = 0;
this.setState("connected");
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
if (message.type === "event") {
this.notifySubscribers(message.event, message.data);
}
} catch (e) {
console.error("[WS] Failed to parse message:", e);
}
};
this.ws.onclose = (event) => {
console.log(`[WS] Disconnected (code: ${event.code})`);
this.ws = null;
this.setState("disconnected");
this.scheduleReconnect();
};
this.ws.onerror = (error) => {
console.error("[WS] Error:", error);
};
} catch (e) {
console.error("[WS] Connection failed:", e);
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
// Only reconnect if there are still subscribers
if (this.getTotalSubscriberCount() === 0) {
return;
}
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
}
const delay = Math.min(
this.INITIAL_DELAY *
Math.pow(this.BACKOFF_MULTIPLIER, this.reconnectAttempts),
this.MAX_DELAY
);
console.log(
`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`
);
this.reconnectTimeout = setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, delay);
}
disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.setState("disconnected");
}
subscribe(eventType: string, handler: EventHandler): () => void {
if (!this.subscribers.has(eventType)) {
this.subscribers.set(eventType, new Set());
}
this.subscribers.get(eventType)!.add(handler);
// Start connection if this is first subscriber
if (this.getTotalSubscriberCount() === 1) {
this.connect();
}
// Return unsubscribe function
return () => {
const handlers = this.subscribers.get(eventType);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.subscribers.delete(eventType);
}
}
// Disconnect if no subscribers left
if (this.getTotalSubscriberCount() === 0) {
this.disconnect();
}
};
}
subscribeToState(listener: (state: ConnectionState) => void): () => void {
this.stateListeners.add(listener);
// Immediately notify of current state
listener(this.state);
return () => {
this.stateListeners.delete(listener);
};
}
private notifySubscribers(
eventType: string,
data: Record<string, unknown>
): void {
const handlers = this.subscribers.get(eventType);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data);
} catch (e) {
console.error(`[WS] Handler error for ${eventType}:`, e);
}
});
}
}
private setState(newState: ConnectionState): void {
this.state = newState;
this.stateListeners.forEach((listener) => listener(newState));
}
private getTotalSubscriberCount(): number {
let count = 0;
this.subscribers.forEach((handlers) => {
count += handlers.size;
});
return count;
}
getState(): ConnectionState {
return this.state;
}
}
/**
* Hook to subscribe to WebSocket events.
*
* @param eventType - The event type to subscribe to (e.g., "system_stats", "ups_battery")
* @param handler - Callback function called when event is received
*
* @example
* ```tsx
* function MyComponent() {
* useWebSocketEvent("system_stats", (data) => {
* console.log("System stats:", data);
* });
* }
* ```
*/
export function useWebSocketEvent(
eventType: string,
handler: EventHandler
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const wsManager = WebSocketManager.getInstance();
const wrappedHandler: EventHandler = (data) => {
handlerRef.current(data);
};
const unsubscribe = wsManager.subscribe(eventType, wrappedHandler);
return unsubscribe;
}, [eventType]);
}
/**
* Hook to get current WebSocket connection state.
*
* @returns Current connection state: "connecting" | "connected" | "disconnected"
*
* @example
* ```tsx
* function StatusIndicator() {
* const connectionState = useWebSocketState();
* return <span>{connectionState}</span>;
* }
* ```
*/
export function useWebSocketState(): ConnectionState {
const [state, setState] = useState<ConnectionState>("disconnected");
useEffect(() => {
const wsManager = WebSocketManager.getInstance();
const unsubscribe = wsManager.subscribeToState(setState);
return unsubscribe;
}, []);
return state;
}

View File

@@ -0,0 +1,158 @@
import type { LinksFunction } from "@remix-run/node";
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
} from "@remix-run/react";
import { useState, useEffect } from "react";
import styles from "./styles.css?url";
import { PowerWidget } from "./components/PowerWidget";
import { HeaderWidgets } from "./components/HeaderWidgets";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
<meta name="theme-color" content="#0a0e1a" />
<meta name="description" content="Dangerous Pi - Proxmark3 Management Interface" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
const location = useLocation();
const [theme, setTheme] = useState<"auto" | "dark" | "light">("dark");
// Initialize theme (default to dark, honor system preference if available)
useEffect(() => {
const savedTheme = localStorage.getItem("theme") as "auto" | "dark" | "light" | null;
if (savedTheme) {
setTheme(savedTheme);
document.documentElement.setAttribute("data-theme", savedTheme);
} else {
// Default to dark for Dangerous Things cyberpunk aesthetic
setTheme("dark");
document.documentElement.setAttribute("data-theme", "dark");
}
}, []);
const toggleTheme = () => {
const themes: Array<"auto" | "dark" | "light"> = ["dark", "auto", "light"];
const currentIndex = themes.indexOf(theme);
const nextTheme = themes[(currentIndex + 1) % themes.length];
setTheme(nextTheme);
localStorage.setItem("theme", nextTheme);
document.documentElement.setAttribute("data-theme", nextTheme);
};
const navLinks = [
{ to: "/", label: "Dashboard", icon: "◈" },
{ to: "/commands", label: "Commands", icon: "▶" },
{ to: "/settings", label: "Settings", icon: "⚙" },
{ to: "/updates", label: "Updates", icon: "🔄" },
{ to: "/logs", label: "Logs", icon: "≡" },
];
return (
<>
<header className="header">
<div className="header-content">
<a href="/" className="logo">
<div className="logo-icon"></div>
<span>Dangerous Pi</span>
</a>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<PowerWidget />
<button
onClick={toggleTheme}
className="btn-secondary"
style={{ minWidth: "44px", padding: "0.5rem" }}
aria-label="Toggle theme"
title={`Current theme: ${theme}`}
>
{theme === "dark" ? "◐" : theme === "light" ? "○" : "◑"}
</button>
</div>
</div>
</header>
{/* Header Widgets - Notifications from system and plugins */}
<HeaderWidgets />
{/* Desktop Navigation - Hidden on mobile */}
<nav className="nav" style={{ display: "none" }}>
{navLinks.map((link) => (
<a
key={link.to}
href={link.to}
className={`nav-link ${location.pathname === link.to ? "active" : ""}`}
>
<span style={{ fontSize: "1.25rem" }}>{link.icon}</span>
<span>{link.label}</span>
</a>
))}
</nav>
<main className="main">
<Outlet />
</main>
{/* Mobile Bottom Navigation */}
<nav className="nav">
{navLinks.map((link) => (
<a
key={link.to}
href={link.to}
className={`nav-link ${location.pathname === link.to ? "active" : ""}`}
>
<span style={{ fontSize: "1.5rem" }}>{link.icon}</span>
<span>{link.label}</span>
</a>
))}
</nav>
<style>{`
@media (min-width: 768px) {
.nav:first-of-type {
display: flex !important;
position: static;
border: none;
background: transparent;
padding: 1rem 0;
max-width: 1280px;
margin: 0 auto;
justify-content: center;
}
.nav:last-of-type {
display: none;
}
body {
padding-bottom: 0;
}
}
`}</style>
</>
);
}

View File

@@ -0,0 +1,361 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useState } from "react";
import { useWebSocketEvent } from "~/hooks/useWebSocket";
export async function loader() {
try {
// Fetch system info from backend
const [healthRes, devicesRes, systemRes] = await Promise.all([
fetch("http://localhost:8000/api/health"),
fetch("http://localhost:8000/api/pm3/devices"),
fetch("http://localhost:8000/api/system/info"),
]);
const health = await healthRes.json();
const devicesData = await devicesRes.json();
const rawSystemInfo = await systemRes.json();
// Extract devices array from response (API returns {devices: [...]} directly)
const devices = devicesData.devices || [];
// Flatten system info for easier access in UI
const systemInfo = {
hostname: rawSystemInfo.hostname,
cpu_temp: rawSystemInfo.cpu_temp,
cpu_per_core: rawSystemInfo.cpu?.per_core || [],
load_average: rawSystemInfo.cpu?.load_average || null,
memory_used: rawSystemInfo.memory_used,
memory_total: rawSystemInfo.memory_total,
disk_used: rawSystemInfo.disk_used,
disk_total: rawSystemInfo.disk_total,
};
return json({ health, devices, systemInfo });
} catch (error) {
// Return mock data if backend is not available
return json({
health: { status: "unknown", version: "0.1.0" },
devices: [],
systemInfo: {
hostname: "dangerous-pi",
cpu_temp: null,
cpu_per_core: [],
load_average: null,
memory_used: 0,
memory_total: 0,
disk_used: 0,
disk_total: 0,
},
});
}
}
interface SystemStats {
cpu_percent: number;
cpu_per_core: { core_id: number; percent: number; online?: boolean }[];
load_average: number[];
memory_percent: number;
temperature: number | null;
}
interface PM3DeviceData {
device_id: string;
device_path: string;
status: string;
friendly_name: string;
firmware_info?: { os_version?: string };
}
export default function Dashboard() {
const loaderData = useLoaderData<typeof loader>();
const [eventMessage, setEventMessage] = useState<string | null>(null);
// Real-time state from WebSocket
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [pm3Devices, setPm3Devices] = useState<PM3DeviceData[] | null>(null);
// Merge real-time data with loader data
const data = {
...loaderData,
systemInfo: {
...loaderData.systemInfo,
...(systemStats && {
cpu_per_core: systemStats.cpu_per_core,
load_average: systemStats.load_average,
cpu_temp: systemStats.temperature ?? loaderData.systemInfo.cpu_temp,
}),
},
devices: pm3Devices ?? loaderData.devices,
};
// WebSocket event subscriptions for real-time updates
useWebSocketEvent("connected", (data) => {
console.log("WebSocket connected:", data);
});
useWebSocketEvent("pm3_status", (data) => {
setEventMessage(`PM3: ${data.message}`);
setTimeout(() => setEventMessage(null), 5000);
});
// PM3 devices - real-time on connect/disconnect
useWebSocketEvent("pm3_devices", (data) => {
setPm3Devices(data.devices as PM3DeviceData[]);
});
// System stats - real-time every 5s from backend
useWebSocketEvent("system_stats", (data) => {
setSystemStats(data as SystemStats);
});
useWebSocketEvent("update_available", (data) => {
setEventMessage(`Update available: ${data.version}`);
});
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
};
const memoryPercent = data.systemInfo.memory_total > 0
? ((data.systemInfo.memory_used / data.systemInfo.memory_total) * 100).toFixed(1)
: "0";
const diskPercent = data.systemInfo.disk_total > 0
? ((data.systemInfo.disk_used / data.systemInfo.disk_total) * 100).toFixed(1)
: "0";
return (
<div className="container">
<h1 style={{ marginBottom: "var(--space-6)" }}>
<span style={{ color: "var(--color-primary)" }}></span> Dashboard
</h1>
{eventMessage && (
<div className="toast">
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
<span style={{ fontSize: "1.5rem" }}></span>
<span>{eventMessage}</span>
</div>
</div>
)}
<div className="card-grid" style={{ marginBottom: "var(--space-6)" }}>
{/* System Status */}
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-primary)" }}></span> System Status
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Backend</span>
<span className={`badge ${data.health.status === "healthy" ? "badge-success" : "badge-error"}`}>
<span aria-hidden="true"></span>
{data.health.status}
</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Hostname</span>
<span className="text-primary">{data.systemInfo.hostname || "unknown"}</span>
</div>
{data.systemInfo.cpu_temp && (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">CPU Temp</span>
<span className={data.systemInfo.cpu_temp > 70 ? "text-warning" : "text-primary"}>
{data.systemInfo.cpu_temp.toFixed(1)}°C
</span>
</div>
)}
{/* CPU Load per Core */}
{data.systemInfo.cpu_per_core && data.systemInfo.cpu_per_core.length > 0 && (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
<span className="text-secondary">CPU Cores</span>
{data.systemInfo.load_average && (
<span className="text-muted" style={{ fontSize: "0.75rem" }}>
Load: {data.systemInfo.load_average.map((l: number) => l.toFixed(2)).join(" / ")}
</span>
)}
</div>
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
{data.systemInfo.cpu_per_core.map((core: { core_id: number; percent: number; online?: boolean }) => {
const isOnline = core.online !== false;
return (
<div
key={core.core_id}
className="cpu-core-bar"
style={{
flex: "1 1 auto",
minWidth: "50px",
textAlign: "center",
opacity: isOnline ? 1 : 0.4,
}}
title={isOnline ? `Core ${core.core_id}: ${core.percent.toFixed(0)}%` : `Core ${core.core_id}: Disabled`}
>
<div
style={{
height: "4px",
background: "var(--color-border)",
borderRadius: "2px",
overflow: "hidden",
marginBottom: "var(--space-1)",
}}
>
<div
style={{
height: "100%",
width: isOnline ? `${Math.min(100, core.percent)}%` : "0%",
background: !isOnline
? "var(--color-text-muted)"
: core.percent > 80
? "var(--color-error)"
: core.percent > 50
? "var(--color-warning)"
: "var(--color-accent)",
transition: "width 0.3s ease",
}}
/>
</div>
<span style={{
fontSize: "0.7rem",
fontFamily: "var(--font-mono)",
color: isOnline ? "inherit" : "var(--color-text-muted)",
textDecoration: isOnline ? "none" : "line-through",
}}>
C{core.core_id}: {isOnline ? `${core.percent.toFixed(0)}%` : "OFF"}
</span>
</div>
);
})}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Memory</span>
<span className="text-primary">
{formatBytes(data.systemInfo.memory_used)} / {formatBytes(data.systemInfo.memory_total)} ({memoryPercent}%)
</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="text-secondary">Disk</span>
<span className="text-primary">
{formatBytes(data.systemInfo.disk_used)} / {formatBytes(data.systemInfo.disk_total)} ({diskPercent}%)
</span>
</div>
</div>
</div>
{/* PM3 Devices */}
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-accent)" }}></span> Proxmark3 Devices ({data.devices.length})
</h3>
{data.devices.length === 0 ? (
<div style={{ textAlign: "center", padding: "var(--space-4)" }}>
<div style={{ fontSize: "2rem", marginBottom: "var(--space-2)", opacity: 0.5 }}>🔌</div>
<p className="text-secondary" style={{ marginBottom: "var(--space-1)" }}>
No devices detected
</p>
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
Connect a Proxmark3 via USB
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-3)" }}>
{data.devices.map((device: any) => {
const isConnected = device.status === "connected";
const isInUse = device.status === "in_use";
const deviceName = device.friendly_name || device.device_path;
return (
<div
key={device.device_id}
style={{
padding: "var(--space-3)",
background: "var(--color-bg)",
borderRadius: "var(--radius)",
border: "1px solid var(--color-border)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-2)" }}>
<span style={{ fontWeight: 600, fontSize: "1rem" }}>{deviceName}</span>
<span
className={`badge ${
isConnected
? "badge-success"
: isInUse
? "badge-warning"
: "badge-error"
} badge-pulse`}
>
<span aria-hidden="true"></span>
{isConnected ? "Connected" : isInUse ? "In Use" : device.status}
</span>
</div>
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted)" }}>
<div style={{ marginBottom: "var(--space-1)" }}>
<span style={{ fontFamily: "var(--font-mono)" }}>{device.device_path}</span>
</div>
{device.firmware_info && (
<div>
{device.firmware_info.os_version}
</div>
)}
</div>
</div>
);
})}
</div>
)}
<div style={{ marginTop: "var(--space-4)", display: "flex", gap: "var(--space-2)" }}>
<a href="/commands" className="btn btn-primary" style={{ flex: 1 }}>
{data.devices.length > 0 ? "Select Device & Start" : "Waiting for Device..."}
</a>
</div>
</div>
{/* Quick Actions */}
<div className="card">
<h3 className="card-title">
<span style={{ color: "var(--color-secondary)" }}></span> Quick Actions
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
<a href="/commands?cmd=hf+search" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
<span>🔍</span>
<span>HF Search</span>
</a>
<a href="/commands?cmd=lf+search" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
<span>🔍</span>
<span>LF Search</span>
</a>
<a href="/commands?cmd=hw+tune" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
<span>📡</span>
<span>Tune Antenna</span>
</a>
<a href="/settings" className="btn btn-secondary" style={{ justifyContent: "flex-start" }}>
<span></span>
<span>Settings</span>
</a>
</div>
</div>
</div>
{/* Footer */}
<div style={{ textAlign: "center", padding: "var(--space-6) 0", color: "var(--color-text-muted)" }}>
<p>
<strong style={{ color: "var(--color-primary)" }}>Dangerous Pi</strong> v{data.health.version}
</p>
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
Built for <a href="https://dangerousthings.com" target="_blank" rel="noopener noreferrer">Dangerous Things</a>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,392 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { useState, useEffect, useRef } from "react";
import DeviceSelector from "../components/DeviceSelector";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const command = formData.get("command") as string;
const sessionId = formData.get("sessionId") as string;
const deviceId = formData.get("deviceId") as string;
try {
const response = await fetch("http://localhost:8000/api/pm3/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command,
session_id: sessionId,
device_id: deviceId,
}),
});
const result = await response.json();
return json(result);
} catch (error) {
return json({
success: false,
output: "",
error: `Failed to execute command: ${error}`,
});
}
}
// Helper to get/set session from localStorage
const SESSION_STORAGE_KEY = "pm3_sessions";
function getStoredSession(deviceId: string): string | null {
if (typeof window === "undefined") return null;
try {
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
return sessions[deviceId] || null;
} catch {
return null;
}
}
function storeSession(deviceId: string, sessionId: string): void {
if (typeof window === "undefined") return;
try {
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
sessions[deviceId] = sessionId;
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
} catch {
// Ignore storage errors
}
}
function clearStoredSession(deviceId: string): void {
if (typeof window === "undefined") return;
try {
const sessions = JSON.parse(localStorage.getItem(SESSION_STORAGE_KEY) || "{}");
delete sessions[deviceId];
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(sessions));
} catch {
// Ignore storage errors
}
}
export default function Commands() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [sessionId, setSessionId] = useState<string | null>(null);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const outputRef = useRef<HTMLDivElement>(null);
const isSubmitting = navigation.state === "submitting";
// Create or reuse session when device is selected
useEffect(() => {
if (!selectedDeviceId) {
setSessionId(null);
setSessionError(null);
return;
}
async function ensureSession() {
// First, check if we have a stored session for this device
const storedSessionId = getStoredSession(selectedDeviceId);
if (storedSessionId) {
// Try to verify the stored session is still valid by using it
// We'll set it and if commands fail, we'll create a new one
setSessionId(storedSessionId);
setSessionError(null);
return;
}
// No stored session, create a new one
try {
const response = await fetch("/api/system/session/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: selectedDeviceId,
force_takeover: true, // Take over any stale sessions
}),
});
const data = await response.json();
if (data.success) {
setSessionId(data.session_id);
storeSession(selectedDeviceId, data.session_id);
setSessionError(null);
} else {
setSessionId(null);
clearStoredSession(selectedDeviceId);
setSessionError(data.error || "Failed to create session");
}
} catch (error) {
console.error("Failed to create session:", error);
setSessionId(null);
setSessionError("Network error while creating session");
}
}
ensureSession();
}, [selectedDeviceId]);
// Pre-fill command from URL param
const prefilledCommand = searchParams.get("cmd")?.replace(/\+/g, " ") || "";
// Add command to history
useEffect(() => {
if (actionData && "output" in actionData) {
const form = document.querySelector("form") as HTMLFormElement;
const commandInput = form?.querySelector('input[name="command"]') as HTMLInputElement;
if (commandInput?.value) {
setCommandHistory((prev) => [commandInput.value, ...prev].slice(0, 50));
setHistoryIndex(-1);
}
}
}, [actionData]);
// Auto-scroll output
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [actionData]);
// Handle keyboard shortcuts
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowUp") {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
if (inputRef.current) {
inputRef.current.value = commandHistory[newIndex];
}
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
if (inputRef.current) {
inputRef.current.value = commandHistory[newIndex];
}
} else if (historyIndex === 0) {
setHistoryIndex(-1);
if (inputRef.current) {
inputRef.current.value = "";
}
}
}
};
const quickCommands = [
{ label: "HW Status", cmd: "hw status", icon: "◈" },
{ label: "HW Version", cmd: "hw version", icon: "ⓘ" },
{ label: "HW Tune", cmd: "hw tune", icon: "📡" },
{ label: "HF Search", cmd: "hf search", icon: "🔍" },
{ label: "LF Search", cmd: "lf search", icon: "🔍" },
{ label: "HF List", cmd: "hf list", icon: "≡" },
];
return (
<div className="container">
<h1 style={{ marginBottom: "var(--space-6)" }}>
<span style={{ color: "var(--color-accent)" }}></span> PM3 Commands
</h1>
{/* Device Selector */}
<div style={{ marginBottom: "var(--space-4)" }}>
<DeviceSelector
selectedDeviceId={selectedDeviceId}
onDeviceSelect={setSelectedDeviceId}
showIdentifyButton={true}
autoSelectSingle={true}
/>
</div>
{/* Session Error/Warning */}
{selectedDeviceId && sessionError && (
<div className="card" style={{ marginBottom: "var(--space-4)", borderColor: "var(--color-error)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
<span style={{ fontSize: "1.5rem" }}></span>
<div>
<strong>Session Error</strong>
<p className="text-secondary" style={{ marginTop: "var(--space-1)", marginBottom: 0 }}>
{sessionError}
</p>
</div>
</div>
</div>
)}
{selectedDeviceId && !sessionId && !sessionError && (
<div className="card" style={{ marginBottom: "var(--space-4)", borderColor: "var(--color-warning)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)" }}>
<span style={{ fontSize: "1.5rem" }}></span>
<div>
<strong>Creating Session...</strong>
<p className="text-secondary" style={{ marginTop: "var(--space-1)", marginBottom: 0 }}>
Establishing connection to device...
</p>
</div>
</div>
</div>
)}
{/* Quick Commands */}
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
<h3 className="card-title">Quick Commands</h3>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: "var(--space-2)" }}>
{quickCommands.map((qc) => (
<button
key={qc.cmd}
onClick={() => {
if (inputRef.current) {
inputRef.current.value = qc.cmd;
inputRef.current.focus();
}
}}
className="btn btn-secondary"
style={{ fontSize: "0.75rem", padding: "var(--space-2) var(--space-3)" }}
>
<span>{qc.icon}</span>
<span>{qc.label}</span>
</button>
))}
</div>
</div>
{/* Command Input */}
<div className="card" style={{ marginBottom: "var(--space-4)" }}>
<Form method="post">
<input type="hidden" name="sessionId" value={sessionId || ""} />
<input type="hidden" name="deviceId" value={selectedDeviceId || ""} />
<div className="form-group">
<label htmlFor="command" className="label">
Command
</label>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<input
ref={inputRef}
type="text"
id="command"
name="command"
className="input"
placeholder={
!selectedDeviceId
? "Select a device first..."
: !sessionId
? "Creating session..."
: "Enter PM3 command (e.g., hw status)"
}
defaultValue={prefilledCommand}
onKeyDown={handleKeyDown}
disabled={!selectedDeviceId || !sessionId || isSubmitting}
autoComplete="off"
autoFocus={!!sessionId}
style={{ flex: 1 }}
/>
<button
type="submit"
className="btn btn-primary"
disabled={!selectedDeviceId || !sessionId || isSubmitting}
style={{ minWidth: "100px" }}
>
{isSubmitting ? (
<>
<span className="spinner"></span>
<span>Running...</span>
</>
) : (
<>
<span></span>
<span>Execute</span>
</>
)}
</button>
</div>
<p className="text-muted" style={{ fontSize: "0.75rem", marginTop: "var(--space-2)", marginBottom: 0 }}>
{selectedDeviceId && sessionId
? "Use ↑/↓ arrow keys to navigate command history"
: !selectedDeviceId
? "Select a Proxmark3 device above to start executing commands"
: "Waiting for session..."}
</p>
</div>
</Form>
</div>
{/* Output Terminal */}
<div className="card">
<h3 className="card-title">Output</h3>
{actionData ? (
<div ref={outputRef} className="terminal">
{actionData.success ? (
<>
<div style={{ color: "var(--color-primary)", marginBottom: "var(--space-2)" }}>
Command executed successfully
</div>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordWrap: "break-word" }}>
{actionData.output || "(No output)"}
</pre>
</>
) : (
<>
<div style={{ color: "var(--color-error)", marginBottom: "var(--space-2)" }}>
Command failed
</div>
<pre style={{ margin: 0, color: "var(--color-error)", whiteSpace: "pre-wrap", wordWrap: "break-word" }}>
{actionData.error || "Unknown error"}
</pre>
</>
)}
</div>
) : (
<div className="terminal" style={{ color: "var(--color-text-muted)", fontStyle: "italic" }}>
Ready. Enter a command above and press Execute.
<br /><br />
<span style={{ color: "var(--color-text-secondary)" }}>Examples:</span>
<br />
hw status Check hardware status
<br />
hw version Show firmware version
<br />
hf search Search for HF tags
<br />
lf search Search for LF tags
</div>
)}
</div>
{/* Command History */}
{commandHistory.length > 0 && (
<div className="card" style={{ marginTop: "var(--space-4)" }}>
<h3 className="card-title">Recent Commands</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-2)" }}>
{commandHistory.slice(0, 10).map((cmd, idx) => (
<button
key={idx}
onClick={() => {
if (inputRef.current) {
inputRef.current.value = cmd;
inputRef.current.focus();
}
}}
className="btn btn-secondary"
style={{
justifyContent: "flex-start",
fontFamily: "var(--font-mono)",
fontSize: "0.875rem",
textAlign: "left",
}}
>
<span style={{ color: "var(--color-text-muted)" }}></span>
<span>{cmd}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader() {
try {
const response = await fetch("http://localhost:8000/api/pm3/commands/history?limit=50");
const data = await response.json();
return json({ history: data.history || [] });
} catch (error) {
// Return mock data if backend unavailable
return json({
history: [
{
id: 1,
command: "hw version",
success: true,
executed_at: new Date().toISOString(),
},
{
id: 2,
command: "hw status",
success: true,
executed_at: new Date(Date.now() - 300000).toISOString(),
},
],
});
}
}
export default function Logs() {
const { history } = useLoaderData<typeof loader>();
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
};
return (
<div className="container">
<h1 style={{ marginBottom: "var(--space-6)" }}>
<span style={{ color: "var(--color-info)" }}></span> Command Logs
</h1>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-4)" }}>
<h3 className="card-title" style={{ marginBottom: 0 }}>
Command History
</h3>
<div style={{ display: "flex", gap: "var(--space-2)" }}>
<button className="btn btn-secondary" disabled>
<span></span>
<span>Export CSV</span>
</button>
<button className="btn btn-danger" disabled>
<span>🗑</span>
<span>Clear</span>
</button>
</div>
</div>
{history.length === 0 ? (
<div className="terminal" style={{ textAlign: "center", color: "var(--color-text-muted)" }}>
<p>No command history yet.</p>
<p style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
Execute commands from the{" "}
<a href="/commands">Commands page</a>{" "}
to see them here.
</p>
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
<th style={{ padding: "var(--space-3)", textAlign: "left", color: "var(--color-text-secondary)", fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Time
</th>
<th style={{ padding: "var(--space-3)", textAlign: "left", color: "var(--color-text-secondary)", fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Command
</th>
<th style={{ padding: "var(--space-3)", textAlign: "left", color: "var(--color-text-secondary)", fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Status
</th>
</tr>
</thead>
<tbody>
{history.map((entry: any, idx: number) => (
<tr
key={entry.id || idx}
style={{
borderBottom: "1px solid var(--color-border)",
transition: "background var(--transition-fast)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<td style={{ padding: "var(--space-3)", fontSize: "0.875rem", color: "var(--color-text-secondary)", fontFamily: "var(--font-mono)" }}>
{formatDate(entry.executed_at)}
</td>
<td style={{ padding: "var(--space-3)", fontFamily: "var(--font-mono)", fontSize: "0.875rem" }}>
<code style={{ color: "var(--color-accent)" }}>
{entry.command}
</code>
</td>
<td style={{ padding: "var(--space-3)" }}>
<span className={`badge ${entry.success ? "badge-success" : "badge-error"}`}>
<span aria-hidden="true">{entry.success ? "✓" : "✗"}</span>
{entry.success ? "Success" : "Failed"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{history.length > 0 && (
<div style={{ marginTop: "var(--space-4)", textAlign: "center", color: "var(--color-text-muted)", fontSize: "0.875rem" }}>
Showing {history.length} recent commands
</div>
)}
</div>
<div className="card" style={{ marginTop: "var(--space-4)" }}>
<h3 className="card-title">System Logs</h3>
<p className="text-muted">System log viewing will be available in a future update.</p>
<button className="btn btn-secondary" disabled>
<span>📄</span>
<span>View System Logs</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,426 @@
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/node";
import { useLoaderData, useActionData, Form, useNavigation } from "@remix-run/react";
import { useState, useEffect } from "react";
interface UpdateInfo {
update_available: boolean;
current_version: string;
latest_version?: string;
release_date?: string;
changelog?: string;
is_prerelease: boolean;
download_size?: number;
message?: string;
}
interface UpdateProgress {
status: string;
current_version: string;
available_version?: string;
download_progress: number;
error_message?: string;
last_check?: string;
}
export async function loader({ request }: LoaderFunctionArgs) {
try {
// Fetch current update status and progress
const [updateRes, progressRes] = await Promise.all([
fetch("http://localhost:8000/api/updates/check"),
fetch("http://localhost:8000/api/updates/progress"),
]);
const updateInfo = await updateRes.json();
const progressInfo = await progressRes.json();
return json({ updateInfo, progressInfo });
} catch (error) {
console.error("Error loading update info:", error);
return json({
updateInfo: null,
progressInfo: null,
error: "Failed to load update information",
});
}
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const action = formData.get("_action");
try {
if (action === "check_updates") {
const response = await fetch("http://localhost:8000/api/updates/check", {
method: "GET",
});
const data = await response.json();
return json({ success: true, message: "Update check complete", data });
}
if (action === "download_update") {
const response = await fetch("http://localhost:8000/api/updates/download", {
method: "POST",
});
const data = await response.json();
return json({ success: true, message: "Download started", data });
}
if (action === "install_update") {
const response = await fetch("http://localhost:8000/api/updates/install", {
method: "POST",
});
const data = await response.json();
return json({ success: true, message: "Installation started", data });
}
return json({ success: false, message: "Unknown action" });
} catch (error: any) {
return json({ success: false, message: error.message || "Action failed" });
}
}
export default function Updates() {
const { updateInfo, progressInfo, error } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const [progress, setProgress] = useState<UpdateProgress | null>(progressInfo);
// Poll for progress updates when downloading or installing
useEffect(() => {
if (!progress || (progress.status !== "downloading" && progress.status !== "installing")) {
return;
}
const interval = setInterval(async () => {
try {
const response = await fetch("http://localhost:8000/api/updates/progress");
const data = await response.json();
setProgress(data);
} catch (error) {
console.error("Error polling progress:", error);
}
}, 1000);
return () => clearInterval(interval);
}, [progress?.status]);
const getStatusBadge = (status: string) => {
const badges: Record<string, { text: string; color: string }> = {
idle: { text: "Idle", color: "var(--color-text-muted)" },
checking: { text: "Checking...", color: "var(--color-primary)" },
available: { text: "Update Available", color: "var(--color-accent)" },
downloading: { text: "Downloading", color: "var(--color-primary)" },
installing: { text: "Installing", color: "var(--color-warning)" },
complete: { text: "Complete", color: "var(--color-success)" },
failed: { text: "Failed", color: "var(--color-danger)" },
};
const badge = badges[status] || badges.idle;
return (
<span
className="badge"
style={{
backgroundColor: `${badge.color}20`,
color: badge.color,
border: `1px solid ${badge.color}40`,
}}
>
{badge.text}
</span>
);
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
if (error) {
return (
<div className="container">
<h1> Error</h1>
<div className="card">
<p style={{ color: "var(--color-danger)" }}>{error}</p>
</div>
</div>
);
}
return (
<div className="container">
<div style={{ marginBottom: "var(--space-6)" }}>
<h1 style={{ marginBottom: "var(--space-2)" }}>
<span style={{ color: "var(--color-primary)" }}>🔄</span> System Updates
</h1>
<p className="text-muted">
Manage system updates from GitHub releases
</p>
</div>
{/* Action Messages */}
{actionData && (
<div
className="card"
style={{
marginBottom: "var(--space-6)",
backgroundColor: actionData.success
? "var(--color-success-bg)"
: "var(--color-danger-bg)",
borderColor: actionData.success
? "var(--color-success)"
: "var(--color-danger)",
}}
>
<p style={{ margin: 0 }}>{actionData.message}</p>
</div>
)}
{/* Current Version */}
<div className="card" style={{ marginBottom: "var(--space-6)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-4)" }}>
<div>
<h2 className="card-title" style={{ marginBottom: "var(--space-2)" }}>
Current Version
</h2>
<div style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-primary)" }}>
v{progressInfo?.current_version || updateInfo?.current_version || "Unknown"}
</div>
</div>
{progress && getStatusBadge(progress.status)}
</div>
{progressInfo?.last_check && (
<p className="text-muted" style={{ fontSize: "0.875rem", marginTop: "var(--space-2)" }}>
Last checked: {new Date(progressInfo.last_check).toLocaleString()}
</p>
)}
<Form method="post" style={{ marginTop: "var(--space-4)" }}>
<input type="hidden" name="_action" value="check_updates" />
<button
type="submit"
className="btn btn-secondary"
disabled={isSubmitting || progress?.status === "checking"}
>
{isSubmitting || progress?.status === "checking" ? (
<>
<span className="spinner"></span>
<span>Checking...</span>
</>
) : (
<>
<span>🔍</span>
<span>Check for Updates</span>
</>
)}
</button>
</Form>
</div>
{/* Update Available */}
{updateInfo?.update_available && (
<div className="card" style={{ marginBottom: "var(--space-6)", borderColor: "var(--color-accent)" }}>
<h2 className="card-title" style={{ marginBottom: "var(--space-4)" }}>
<span style={{ color: "var(--color-accent)" }}></span> Update Available
</h2>
<div style={{ marginBottom: "var(--space-4)" }}>
<div style={{ display: "flex", gap: "var(--space-4)", marginBottom: "var(--space-3)" }}>
<div>
<div className="label" style={{ fontSize: "0.75rem" }}>New Version</div>
<div style={{ fontSize: "1.25rem", fontWeight: 700, color: "var(--color-accent)" }}>
v{updateInfo.latest_version}
</div>
</div>
{updateInfo.download_size && (
<div>
<div className="label" style={{ fontSize: "0.75rem" }}>Download Size</div>
<div style={{ fontSize: "1.25rem", fontWeight: 600 }}>
{formatBytes(updateInfo.download_size)}
</div>
</div>
)}
</div>
{updateInfo.release_date && (
<p className="text-muted" style={{ fontSize: "0.875rem" }}>
Released: {formatDate(updateInfo.release_date)}
</p>
)}
{updateInfo.is_prerelease && (
<div
className="badge"
style={{
backgroundColor: "var(--color-warning-bg)",
color: "var(--color-warning)",
marginTop: "var(--space-2)",
}}
>
Pre-release
</div>
)}
</div>
{/* Changelog */}
{updateInfo.changelog && (
<div style={{ marginBottom: "var(--space-4)" }}>
<div className="label" style={{ marginBottom: "var(--space-2)" }}>Release Notes</div>
<div
style={{
padding: "var(--space-3)",
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
maxHeight: "300px",
overflowY: "auto",
fontSize: "0.875rem",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
}}
>
{updateInfo.changelog}
</div>
</div>
)}
{/* Update Actions */}
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
{progress?.status === "available" && (
<Form method="post">
<input type="hidden" name="_action" value="download_update" />
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? (
<>
<span className="spinner"></span>
<span>Starting...</span>
</>
) : (
<>
<span></span>
<span>Download Update</span>
</>
)}
</button>
</Form>
)}
{progress?.status === "downloading" && (
<div style={{ flex: 1 }}>
<div style={{ marginBottom: "var(--space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.875rem" }}>
<span>Downloading...</span>
<span>{Math.round(progress.download_progress)}%</span>
</div>
<div
style={{
width: "100%",
height: "8px",
background: "var(--color-border)",
borderRadius: "var(--radius)",
overflow: "hidden",
marginTop: "var(--space-2)",
}}
>
<div
style={{
width: `${progress.download_progress}%`,
height: "100%",
background: "linear-gradient(90deg, var(--color-primary), var(--color-accent))",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
</div>
)}
{(progress?.status === "idle" || progress?.download_progress === 100) &&
updateInfo.update_available && (
<Form method="post">
<input type="hidden" name="_action" value="install_update" />
<button type="submit" className="btn btn-accent" disabled={isSubmitting}>
{isSubmitting ? (
<>
<span className="spinner"></span>
<span>Installing...</span>
</>
) : (
<>
<span></span>
<span>Install Update</span>
</>
)}
</button>
</Form>
)}
{progress?.status === "installing" && (
<div className="btn btn-accent" style={{ cursor: "default" }}>
<span className="spinner"></span>
<span>Installing Update...</span>
</div>
)}
{progress?.status === "complete" && (
<div style={{ flex: 1 }}>
<div
className="card"
style={{
backgroundColor: "var(--color-success-bg)",
borderColor: "var(--color-success)",
padding: "var(--space-3)",
}}
>
<p style={{ margin: 0 }}>
Update installed successfully! Please restart the service for changes to take effect.
</p>
</div>
</div>
)}
</div>
{progress?.error_message && (
<div
className="card"
style={{
marginTop: "var(--space-4)",
backgroundColor: "var(--color-danger-bg)",
borderColor: "var(--color-danger)",
padding: "var(--space-3)",
}}
>
<p style={{ margin: 0, color: "var(--color-danger)" }}>
{progress.error_message}
</p>
</div>
)}
</div>
)}
{/* No Update Available */}
{updateInfo && !updateInfo.update_available && updateInfo.message && (
<div className="card">
<p style={{ margin: 0, textAlign: "center", color: "var(--color-text-muted)" }}>
{updateInfo.message}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,827 @@
/* Dangerous Pi - Cyberpunk Theme */
/* Optimized for Pi Zero 2 W - Minimal, Fast, Beautiful */
:root {
/* Cyberpunk Color Palette - Dark Mode Default */
--color-bg: #0a0e1a;
--color-surface: #121827;
--color-surface-hover: #1a2332;
--color-border: #1e293b;
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
/* Neon Accents */
--color-primary: #00ffff; /* Cyan */
--color-primary-dim: #0891b2;
--color-secondary: #ff00ff; /* Magenta */
--color-accent: #00ff88; /* Green */
/* Status Colors */
--color-success: #00ff88;
--color-warning: #ffaa00;
--color-error: #ff0055;
--color-info: #00ffff;
/* Spacing (4px base) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius: 0.5rem;
--radius-lg: 0.75rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition: 250ms ease;
/* Monospace for terminal feel */
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Light mode override (if user prefers) */
@media (prefers-color-scheme: light) {
:root[data-theme="auto"] {
--color-bg: #ffffff;
--color-surface: #f8fafc;
--color-surface-hover: #f1f5f9;
--color-border: #e2e8f0;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-primary: #0891b2;
--color-primary-dim: #0e7490;
--color-secondary: #c026d3;
}
}
/* Force dark mode */
:root[data-theme="dark"] {
color-scheme: dark;
}
/* Force light mode */
:root[data-theme="light"] {
--color-bg: #ffffff;
--color-surface: #f8fafc;
--color-surface-hover: #f1f5f9;
--color-border: #e2e8f0;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-primary: #0891b2;
--color-primary-dim: #0e7490;
--color-secondary: #c026d3;
color-scheme: light;
}
/* Reset & Base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background: var(--color-bg);
color: var(--color-text-primary);
font-size: 1rem;
line-height: 1.5;
min-height: 100vh;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
margin-bottom: var(--space-4);
}
h1 { font-size: 1.875rem; } /* 30px */
h2 { font-size: 1.5rem; } /* 24px */
h3 { font-size: 1.25rem; } /* 20px */
h4 { font-size: 1.125rem; } /* 18px */
p {
margin-bottom: var(--space-4);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--color-accent);
}
/* Layout */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--space-4);
}
/* Header */
.header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: var(--space-4);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
background: rgba(18, 24, 39, 0.9);
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1280px;
margin: 0 auto;
}
.logo {
display: flex;
align-items: center;
gap: var(--space-3);
font-weight: 700;
font-size: 1.25rem;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.logo-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
border-radius: var(--radius);
}
/* Navigation */
.nav {
display: flex;
gap: var(--space-2);
}
.nav-link {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
color: var(--color-text-secondary);
font-weight: 500;
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.nav-link:hover {
color: var(--color-primary);
background: var(--color-surface-hover);
border-color: var(--color-primary);
}
.nav-link.active {
color: var(--color-primary);
border-color: var(--color-primary);
background: rgba(0, 255, 255, 0.1);
}
/* Mobile Navigation */
@media (max-width: 768px) {
.nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: var(--space-2);
justify-content: space-around;
z-index: 100;
}
.nav-link {
flex-direction: column;
align-items: center;
font-size: 0.75rem;
padding: var(--space-2);
min-width: 60px;
}
body {
padding-bottom: 60px; /* Space for bottom nav */
}
}
/* Main Content */
.main {
padding: var(--space-6) var(--space-4);
max-width: 1280px;
margin: 0 auto;
}
/* Cards */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
transition: border-color var(--transition-fast);
}
.card:hover {
border-color: var(--color-primary);
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: var(--space-3);
color: var(--color-text-primary);
}
.card-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border-radius: var(--radius);
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
border: 1px solid transparent;
transition: all var(--transition-fast);
min-height: 44px; /* Touch-friendly */
font-family: var(--font-sans);
}
.btn-primary {
background: var(--color-primary);
color: var(--color-bg);
border-color: var(--color-primary);
box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}
.btn-primary:hover {
background: var(--color-accent);
border-color: var(--color-accent);
box-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
transform: translateY(-1px);
}
.btn-secondary {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
}
.btn-secondary:hover {
background: rgba(0, 255, 255, 0.1);
border-color: var(--color-accent);
color: var(--color-accent);
}
.btn-danger {
background: var(--color-error);
color: white;
border-color: var(--color-error);
}
.btn-danger:hover {
background: #cc0044;
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* Status Badges */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-success {
background: rgba(0, 255, 136, 0.2);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.badge-error {
background: rgba(255, 0, 85, 0.2);
color: var(--color-error);
border: 1px solid var(--color-error);
}
.badge-warning {
background: rgba(255, 170, 0, 0.2);
color: var(--color-warning);
border: 1px solid var(--color-warning);
}
.badge-info {
background: rgba(0, 255, 255, 0.2);
color: var(--color-info);
border: 1px solid var(--color-info);
}
.badge-pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Forms */
.form-group {
margin-bottom: var(--space-4);
}
.label {
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: var(--space-2);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
color: var(--color-text-primary);
font-size: 1rem;
font-family: var(--font-mono);
transition: all var(--transition-fast);
min-height: 44px;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(0, 255, 255, 0.1);
}
.input::placeholder {
color: var(--color-text-muted);
}
/* Terminal/Code Output */
.terminal {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-4);
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-accent);
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.terminal::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.terminal::-webkit-scrollbar-track {
background: var(--color-surface);
}
.terminal::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-sm);
}
.terminal::-webkit-scrollbar-thumb:hover {
background: var(--color-primary);
}
/* Loading States */
.skeleton {
background: linear-gradient(
90deg,
var(--color-surface) 0%,
var(--color-surface-hover) 50%,
var(--color-surface) 100%
);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: var(--radius);
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Toast Notifications */
.toast {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
z-index: 200;
animation: toast-in 250ms ease;
}
@keyframes toast-in {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Utility Classes */
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-muted { color: var(--color-text-muted); }
.text-success { color: var(--color-success); }
.text-error { color: var(--color-error); }
.text-warning { color: var(--color-warning); }
.text-center { text-align: center; }
.text-right { text-align: right; }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: var(--space-2); }
.gap-4 { gap: var(--space-4); }
/* Accessibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Focus Styles */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Responsive */
@media (max-width: 768px) {
h1 { font-size: 1.5rem; }
h2 { font-size: 1.25rem; }
.card {
padding: var(--space-4);
}
.main {
padding: var(--space-4) var(--space-4);
}
}
/* Power Widget */
.power-widget {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
background: var(--color-surface);
border: 1px solid var(--color-border);
cursor: default;
transition: all var(--transition-fast);
position: relative;
}
.power-widget:hover {
border-color: var(--color-primary);
}
.power-widget__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 14px;
}
.power-widget__percentage {
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-mono);
min-width: 2.5em;
text-align: right;
}
.power-widget__plug-indicator {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-accent);
animation: plug-pulse 2s ease-in-out infinite;
}
.plug-icon {
width: 12px;
height: 12px;
}
@keyframes plug-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* Battery Icon */
.battery-icon {
width: 24px;
height: 14px;
color: currentColor;
}
.battery-icon__fill {
transition: width var(--transition);
}
.battery-icon__bolt {
animation: bolt-flash 1s ease-in-out infinite;
}
@keyframes bolt-flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* Power Widget States */
.power-widget--loading {
opacity: 0.6;
}
.power-widget--loading .battery-icon__fill {
animation: loading-fill 1.5s ease-in-out infinite;
}
@keyframes loading-fill {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
.power-widget--normal {
color: var(--color-accent);
}
.power-widget--charging {
color: var(--color-accent);
border-color: rgba(0, 255, 136, 0.3);
}
.power-widget--full {
color: var(--color-accent);
border-color: var(--color-accent);
}
.power-widget--low {
color: var(--color-warning);
border-color: rgba(255, 170, 0, 0.3);
}
.power-widget--critical {
color: var(--color-error);
border-color: rgba(255, 0, 85, 0.3);
animation: critical-pulse 1s ease-in-out infinite;
}
@keyframes critical-pulse {
0%, 100% {
border-color: rgba(255, 0, 85, 0.3);
box-shadow: none;
}
50% {
border-color: var(--color-error);
box-shadow: 0 0 10px rgba(255, 0, 85, 0.4);
}
}
/* Mobile adjustments */
@media (max-width: 768px) {
.power-widget {
padding: var(--space-1) var(--space-2);
}
.power-widget__percentage {
font-size: 0.7rem;
}
}
/* ============================================================================
Header Widgets - Notification banners below header
============================================================================ */
.header-widgets {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.widget {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius);
font-size: 0.9rem;
animation: widget-slide-in 0.3s ease-out;
}
@keyframes widget-slide-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.widget-info {
background: rgba(0, 255, 255, 0.1);
border-left: 3px solid var(--color-info);
color: var(--color-info);
}
.widget-warning {
background: rgba(255, 170, 0, 0.1);
border-left: 3px solid var(--color-warning);
color: var(--color-warning);
}
.widget-error {
background: rgba(255, 0, 85, 0.1);
border-left: 3px solid var(--color-error);
color: var(--color-error);
}
.widget-success {
background: rgba(0, 255, 136, 0.1);
border-left: 3px solid var(--color-success);
color: var(--color-success);
}
.widget-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.widget-message {
flex: 1;
line-height: 1.4;
color: var(--color-text-primary);
}
.widget-action {
padding: var(--space-1) var(--space-3);
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
font-size: 0.85rem;
font-weight: 500;
transition: background var(--transition-fast);
white-space: nowrap;
}
.widget-action:hover {
background: rgba(255, 255, 255, 0.2);
}
.widget-dismiss {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: var(--space-1);
opacity: 0.6;
transition: opacity var(--transition-fast);
flex-shrink: 0;
width: 24px;
height: 24px;
}
.widget-dismiss:hover {
opacity: 1;
}
.dismiss-icon {
width: 12px;
height: 12px;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.header-widgets {
padding: var(--space-2);
}
.widget {
font-size: 0.85rem;
padding: var(--space-2);
gap: var(--space-2);
}
.widget-action {
padding: var(--space-1) var(--space-2);
font-size: 0.8rem;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
import{E as h,i as C,d as M,c as y,m as g,s as E,a as S,g as b,b as F,e as P,f as k,r,u as D,R as z,h as H,j as L,k as O,l as T,n as v}from"./components-DjjSZ9bp.js";/**
* @remix-run/react v2.17.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/function j(u){if(!u)return null;let m=Object.entries(u),s={};for(let[a,e]of m)if(e&&e.__type==="RouteErrorResponse")s[a]=new h(e.status,e.statusText,e.data,e.internal===!0);else if(e&&e.__type==="Error"){if(e.__subType){let i=window[e.__subType];if(typeof i=="function")try{let o=new i(e.message);o.stack=e.stack,s[a]=o}catch{}}if(s[a]==null){let i=new Error(e.message);i.stack=e.stack,s[a]=i}}else s[a]=e;return s}/**
* @remix-run/react v2.17.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/let n,t,c=!1,f;new Promise(u=>{f=u}).catch(()=>{});function B(u){if(!t){if(window.__remixContext.future.v3_singleFetch){if(!n){let d=window.__remixContext.stream;C(d,"No stream found for single fetch decoding"),window.__remixContext.stream=void 0,n=M(d,window).then(l=>{window.__remixContext.state=l.value,n.value=!0}).catch(l=>{n.error=l})}if(n.error)throw n.error;if(!n.value)throw n}let i=y(window.__remixManifest.routes,window.__remixRouteModules,window.__remixContext.state,window.__remixContext.future,window.__remixContext.isSpaMode),o;if(!window.__remixContext.isSpaMode){o={...window.__remixContext.state,loaderData:{...window.__remixContext.state.loaderData}};let d=g(i,window.location,window.__remixContext.basename);if(d)for(let l of d){let _=l.route.id,x=window.__remixRouteModules[_],w=window.__remixManifest.routes[_];x&&E(w,x,window.__remixContext.isSpaMode)&&(x.HydrateFallback||!w.hasLoader)?o.loaderData[_]=void 0:w&&!w.hasLoader&&(o.loaderData[_]=null)}o&&o.errors&&(o.errors=j(o.errors))}t=S({routes:i,history:P(),basename:window.__remixContext.basename,future:{v7_normalizeFormMethod:!0,v7_fetcherPersist:window.__remixContext.future.v3_fetcherPersist,v7_partialHydration:!0,v7_prependBasename:!0,v7_relativeSplatPath:window.__remixContext.future.v3_relativeSplatPath,v7_skipActionErrorRevalidation:window.__remixContext.future.v3_singleFetch===!0},hydrationData:o,mapRouteProperties:O,dataStrategy:window.__remixContext.future.v3_singleFetch&&!window.__remixContext.isSpaMode?F(window.__remixManifest,window.__remixRouteModules,()=>t):void 0,patchRoutesOnNavigation:b(window.__remixManifest,window.__remixRouteModules,window.__remixContext.future,window.__remixContext.isSpaMode,window.__remixContext.basename)}),t.state.initialized&&(c=!0,t.initialize()),t.createRoutesForHMR=k,window.__remixRouter=t,f&&f(t)}let[m,s]=r.useState(void 0),[a,e]=r.useState(t.state.location);return r.useLayoutEffect(()=>{c||(c=!0,t.initialize())},[]),r.useLayoutEffect(()=>t.subscribe(i=>{i.location!==a&&e(i.location)}),[a]),D(t,window.__remixManifest,window.__remixRouteModules,window.__remixContext.future,window.__remixContext.isSpaMode),r.createElement(r.Fragment,null,r.createElement(z.Provider,{value:{manifest:window.__remixManifest,routeModules:window.__remixRouteModules,future:window.__remixContext.future,criticalCss:m,isSpaMode:window.__remixContext.isSpaMode}},r.createElement(H,{location:a},r.createElement(L,{router:t,fallbackElement:null,future:{v7_startTransition:!0}}))),window.__remixContext.future.v3_singleFetch?r.createElement(r.Fragment,null):null)}var p,R=T;R.createRoot,p=R.hydrateRoot;r.startTransition(()=>{p(document,v.jsx(r.StrictMode,{children:v.jsx(B,{})}))});

View File

@@ -0,0 +1 @@
import{y as o,n as e}from"./components-DjjSZ9bp.js";function l(){const{history:t}=o(),n=s=>{const r=new Date(s);return new Intl.DateTimeFormat("en-US",{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit",second:"2-digit"}).format(r)};return e.jsxs("div",{className:"container",children:[e.jsxs("h1",{style:{marginBottom:"var(--space-6)"},children:[e.jsx("span",{style:{color:"var(--color-info)"},children:"≡"})," Command Logs"]}),e.jsxs("div",{className:"card",children:[e.jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"var(--space-4)"},children:[e.jsx("h3",{className:"card-title",style:{marginBottom:0},children:"Command History"}),e.jsxs("div",{style:{display:"flex",gap:"var(--space-2)"},children:[e.jsxs("button",{className:"btn btn-secondary",disabled:!0,children:[e.jsx("span",{children:"⬇"}),e.jsx("span",{children:"Export CSV"})]}),e.jsxs("button",{className:"btn btn-danger",disabled:!0,children:[e.jsx("span",{children:"🗑"}),e.jsx("span",{children:"Clear"})]})]})]}),t.length===0?e.jsxs("div",{className:"terminal",style:{textAlign:"center",color:"var(--color-text-muted)"},children:[e.jsx("p",{children:"No command history yet."}),e.jsxs("p",{style:{fontSize:"0.875rem",marginTop:"var(--space-2)"},children:["Execute commands from the"," ",e.jsx("a",{href:"/commands",children:"Commands page"})," ","to see them here."]})]}):e.jsx("div",{style:{overflowX:"auto"},children:e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid var(--color-border)"},children:[e.jsx("th",{style:{padding:"var(--space-3)",textAlign:"left",color:"var(--color-text-secondary)",fontSize:"0.75rem",textTransform:"uppercase",letterSpacing:"0.05em"},children:"Time"}),e.jsx("th",{style:{padding:"var(--space-3)",textAlign:"left",color:"var(--color-text-secondary)",fontSize:"0.75rem",textTransform:"uppercase",letterSpacing:"0.05em"},children:"Command"}),e.jsx("th",{style:{padding:"var(--space-3)",textAlign:"left",color:"var(--color-text-secondary)",fontSize:"0.75rem",textTransform:"uppercase",letterSpacing:"0.05em"},children:"Status"})]})}),e.jsx("tbody",{children:t.map((s,r)=>e.jsxs("tr",{style:{borderBottom:"1px solid var(--color-border)",transition:"background var(--transition-fast)"},onMouseEnter:a=>{a.currentTarget.style.background="var(--color-surface-hover)"},onMouseLeave:a=>{a.currentTarget.style.background="transparent"},children:[e.jsx("td",{style:{padding:"var(--space-3)",fontSize:"0.875rem",color:"var(--color-text-secondary)",fontFamily:"var(--font-mono)"},children:n(s.executed_at)}),e.jsx("td",{style:{padding:"var(--space-3)",fontFamily:"var(--font-mono)",fontSize:"0.875rem"},children:e.jsx("code",{style:{color:"var(--color-accent)"},children:s.command})}),e.jsx("td",{style:{padding:"var(--space-3)"},children:e.jsxs("span",{className:`badge ${s.success?"badge-success":"badge-error"}`,children:[e.jsx("span",{"aria-hidden":"true",children:s.success?"✓":"✗"}),s.success?"Success":"Failed"]})})]},s.id||r))})]})}),t.length>0&&e.jsxs("div",{style:{marginTop:"var(--space-4)",textAlign:"center",color:"var(--color-text-muted)",fontSize:"0.875rem"},children:["Showing ",t.length," recent commands"]})]}),e.jsxs("div",{className:"card",style:{marginTop:"var(--space-4)"},children:[e.jsx("h3",{className:"card-title",children:"System Logs"}),e.jsx("p",{className:"text-muted",children:"System log viewing will be available in a future update."}),e.jsxs("button",{className:"btn btn-secondary",disabled:!0,children:[e.jsx("span",{children:"📄"}),e.jsx("span",{children:"View System Logs"})]})]})]})}export{l as default};

View File

@@ -0,0 +1 @@
window.__remixManifest={"entry":{"module":"/assets/entry.client-CkrwxAV0.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]},"routes":{"root":{"id":"root","path":"","hasAction":false,"hasLoader":false,"hasClientAction":false,"hasClientLoader":false,"hasErrorBoundary":false,"module":"/assets/root-f_Ye4G9l.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]},"routes/commands":{"id":"routes/commands","parentId":"root","path":"commands","hasAction":true,"hasLoader":false,"hasClientAction":false,"hasClientLoader":false,"hasErrorBoundary":false,"module":"/assets/commands-DSGkGbDk.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]},"routes/settings":{"id":"routes/settings","parentId":"root","path":"settings","hasAction":true,"hasLoader":true,"hasClientAction":false,"hasClientLoader":false,"hasErrorBoundary":false,"module":"/assets/settings-BHbKcVlC.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]},"routes/updates":{"id":"routes/updates","parentId":"root","path":"updates","hasAction":true,"hasLoader":true,"hasClientAction":false,"hasClientLoader":false,"hasErrorBoundary":false,"module":"/assets/updates-CNJu1SrW.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]},"routes/_index":{"id":"routes/_index","parentId":"root","index":true,"hasAction":false,"hasLoader":true,"hasClientAction":false,"hasClientLoader":false,"hasErrorBoundary":false,"module":"/assets/_index-CIJCcFpO.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]},"routes/logs":{"id":"routes/logs","parentId":"root","path":"logs","hasAction":false,"hasLoader":true,"hasClientAction":false,"hasClientLoader":false,"hasErrorBoundary":false,"module":"/assets/logs-C_6-dKR0.js","imports":["/assets/components-DjjSZ9bp.js"],"css":[]}},"url":"/assets/manifest-cbba6713.js","version":"cbba6713"};

View File

@@ -0,0 +1,32 @@
import{o as $,p as b,q as E,t as L,r as c,_ as M,n as e,O as k,M as I,L as P,S as T}from"./components-DjjSZ9bp.js";/**
* @remix-run/react v2.17.2
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/let j="positions";function A({getKey:a,...t}){let{isSpaMode:o}=$(),h=b(),m=E();L({getKey:a,storageKey:j});let n=c.useMemo(()=>{if(!a)return null;let r=a(h,m);return r!==h.key?r:null},[]);if(o)return null;let u=((r,l)=>{if(!window.history.state||!window.history.state.key){let i=Math.random().toString(32).slice(2);window.history.replaceState({key:i},"")}try{let p=JSON.parse(sessionStorage.getItem(r)||"{}")[l||window.history.state.key];typeof p=="number"&&window.scrollTo(0,p)}catch(i){console.error(i),sessionStorage.removeItem(r)}}).toString();return c.createElement("script",M({},t,{suppressHydrationWarning:!0,dangerouslySetInnerHTML:{__html:`(${u})(${JSON.stringify(j)}, ${JSON.stringify(n)})`}}))}const O="/assets/styles-D7Zjtr3a.css";function F({apiBase:a="/api"}){const[t,o]=c.useState(null),[h,m]=c.useState(!0),[n,u]=c.useState(null),r=c.useCallback(async()=>{try{const s=await fetch(`${a}/system/ups/status`);if(!s.ok)throw new Error(`HTTP ${s.status}`);const d=await s.json();o(d),u(null)}catch(s){u(s instanceof Error?s.message:"Failed to fetch")}finally{m(!1)}},[a]);if(c.useEffect(()=>{r();let s=null;const d=()=>{s=new EventSource("/sse/events"),s.addEventListener("ups_battery",C=>{try{const w=JSON.parse(C.data);o(y=>y&&{...y,battery_percentage:w.percentage,voltage:w.voltage})}catch(w){console.error("Failed to parse ups_battery event:",w)}}),s.addEventListener("ups_warning",()=>{r()}),s.addEventListener("ups_critical",()=>{r()}),s.onerror=()=>{s==null||s.close(),setTimeout(d,5e3)}};d();const f=setInterval(r,6e4);return()=>{s==null||s.close(),clearInterval(f)}},[a,r]),!h&&(!t||!t.is_available))return null;if(h)return e.jsx("div",{className:"power-widget power-widget--loading",title:"Loading power status...",children:e.jsx("div",{className:"power-widget__icon",children:e.jsx(v,{percentage:50})})});const l=Math.round((t==null?void 0:t.battery_percentage)??0),i=(t==null?void 0:t.power_source)==="ac"||(t==null?void 0:t.battery_status)==="charging",p=l<=10,S=l<=20,_=l>=95&&i;let g="power-widget--normal";p?g="power-widget--critical":S?g="power-widget--low":_?g="power-widget--full":i&&(g="power-widget--charging");const x=[`Battery: ${l}%`,`Source: ${(t==null?void 0:t.power_source)==="ac"?"AC Power":"Battery"}`];if(t!=null&&t.voltage&&t.voltage>0&&x.push(`Voltage: ${(t.voltage/1e3).toFixed(2)}V`),(t==null?void 0:t.current)!==void 0&&(t==null?void 0:t.current)!==null){const s=Math.abs(t.current*1e3).toFixed(0),d=t.current>=0?"charging":"draw";x.push(`Current: ${s}mA ${d}`)}if(t!=null&&t.time_remaining){const s=t.time_remaining;if(s<60)x.push(`Est. runtime: ${s} min`);else{const d=Math.floor(s/60),f=s%60;x.push(`Est. runtime: ${d}h ${f}m`)}}const N=x.join(`
`);return e.jsxs("div",{className:`power-widget ${g}`,title:N,role:"status","aria-label":`Battery at ${l}%${i?", plugged in":", on battery"}`,children:[e.jsx("div",{className:"power-widget__icon",children:e.jsx(v,{percentage:l,isCharging:i})}),e.jsxs("span",{className:"power-widget__percentage",children:[l,"%"]}),i&&e.jsx("span",{className:"power-widget__plug-indicator","aria-label":"Plugged in",title:"AC Power",children:e.jsx(W,{})})]})}function v({percentage:a,isCharging:t=!1}){const o=Math.max(0,Math.min(100,a));return e.jsxs("svg",{className:"battery-icon",viewBox:"0 0 24 14",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",children:[e.jsx("rect",{x:"0.5",y:"0.5",width:"20",height:"13",rx:"2",stroke:"currentColor",strokeWidth:"1",fill:"none"}),e.jsx("rect",{x:"21",y:"4",width:"3",height:"6",rx:"1",fill:"currentColor"}),e.jsx("rect",{className:"battery-icon__fill",x:"2",y:"2",width:Math.max(0,o/100*17),height:"10",rx:"1",fill:"currentColor"}),t&&e.jsx("path",{className:"battery-icon__bolt",d:"M12 1L8 7H11L9 13L14 6H11L12 1Z",fill:"var(--color-bg)",stroke:"currentColor",strokeWidth:"0.5"})]})}function W(){return e.jsxs("svg",{className:"plug-icon",viewBox:"0 0 12 12",fill:"none",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",children:[e.jsx("rect",{x:"3",y:"0",width:"2",height:"4",rx:"0.5",fill:"currentColor"}),e.jsx("rect",{x:"7",y:"0",width:"2",height:"4",rx:"0.5",fill:"currentColor"}),e.jsx("rect",{x:"2",y:"3",width:"8",height:"5",rx:"1",fill:"currentColor"}),e.jsx("rect",{x:"5",y:"8",width:"2",height:"4",rx:"0.5",fill:"currentColor"})]})}const B=()=>[{rel:"stylesheet",href:O}];function D({children:a}){return e.jsxs("html",{lang:"en",children:[e.jsxs("head",{children:[e.jsx("meta",{charSet:"utf-8"}),e.jsx("meta",{name:"viewport",content:"width=device-width, initial-scale=1, maximum-scale=5"}),e.jsx("meta",{name:"theme-color",content:"#0a0e1a"}),e.jsx("meta",{name:"description",content:"Dangerous Pi - Proxmark3 Management Interface"}),e.jsx(I,{}),e.jsx(P,{})]}),e.jsxs("body",{children:[a,e.jsx(A,{}),e.jsx(T,{})]})]})}function J(){const a=b(),[t,o]=c.useState("dark");c.useEffect(()=>{const n=localStorage.getItem("theme");n?(o(n),document.documentElement.setAttribute("data-theme",n)):(o("dark"),document.documentElement.setAttribute("data-theme","dark"))},[]);const h=()=>{const n=["dark","auto","light"],u=n.indexOf(t),r=n[(u+1)%n.length];o(r),localStorage.setItem("theme",r),document.documentElement.setAttribute("data-theme",r)},m=[{to:"/",label:"Dashboard",icon:"◈"},{to:"/commands",label:"Commands",icon:"▶"},{to:"/settings",label:"Settings",icon:"⚙"},{to:"/updates",label:"Updates",icon:"🔄"},{to:"/logs",label:"Logs",icon:"≡"}];return e.jsxs(e.Fragment,{children:[e.jsx("header",{className:"header",children:e.jsxs("div",{className:"header-content",children:[e.jsxs("a",{href:"/",className:"logo",children:[e.jsx("div",{className:"logo-icon"}),e.jsx("span",{children:"Dangerous Pi"})]}),e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:"0.75rem"},children:[e.jsx(F,{}),e.jsx("button",{onClick:h,className:"btn-secondary",style:{minWidth:"44px",padding:"0.5rem"},"aria-label":"Toggle theme",title:`Current theme: ${t}`,children:t==="dark"?"◐":t==="light"?"○":"◑"})]})]})}),e.jsx("nav",{className:"nav",style:{display:"none"},children:m.map(n=>e.jsxs("a",{href:n.to,className:`nav-link ${a.pathname===n.to?"active":""}`,children:[e.jsx("span",{style:{fontSize:"1.25rem"},children:n.icon}),e.jsx("span",{children:n.label})]},n.to))}),e.jsx("main",{className:"main",children:e.jsx(k,{})}),e.jsx("nav",{className:"nav",children:m.map(n=>e.jsxs("a",{href:n.to,className:`nav-link ${a.pathname===n.to?"active":""}`,children:[e.jsx("span",{style:{fontSize:"1.5rem"},children:n.icon}),e.jsx("span",{children:n.label})]},n.to))}),e.jsx("style",{children:`
@media (min-width: 768px) {
.nav:first-of-type {
display: flex !important;
position: static;
border: none;
background: transparent;
padding: 1rem 0;
max-width: 1280px;
margin: 0 auto;
justify-content: center;
}
.nav:last-of-type {
display: none;
}
body {
padding-bottom: 0;
}
}
`})]})}export{D as Layout,J as default,B as links};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "dangerous-pi-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "remix vite:dev",
"build": "remix vite:build",
"start": "remix-serve build/server/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@remix-run/dev": "^2.14.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"typescript": "^5.7.2",
"vite": "^5.4.11"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,23 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"noEmit": true
}
}

View File

@@ -0,0 +1,35 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
}),
],
server: {
port: 3000,
proxy: {
// Proxy API requests to backend
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true, // Enable WebSocket proxying
},
},
},
});

View File

@@ -0,0 +1,87 @@
"""Hello World example plugin for Dangerous Pi.
This plugin demonstrates the plugin framework capabilities including:
- Lifecycle methods (on_load, on_enable, on_disable, on_unload)
- Hook registration
- Header widget display
"""
import sys
# Import PluginBase from the already-loaded module to ensure class identity matches
# This is necessary because the plugin manager uses issubclass() check
# Try both module name variants (depends on how the app is started)
_plugin_manager_module = (
sys.modules.get('app.backend.managers.plugin_manager') or
sys.modules.get('backend.managers.plugin_manager')
)
if _plugin_manager_module:
PluginBase = _plugin_manager_module.PluginBase
PluginMetadata = _plugin_manager_module.PluginMetadata
WidgetSeverity = _plugin_manager_module.WidgetSeverity
else:
# Fallback for standalone testing
from pathlib import Path
app_path = Path(__file__).parent.parent.parent
if str(app_path) not in sys.path:
sys.path.insert(0, str(app_path))
from backend.managers.plugin_manager import PluginBase, PluginMetadata, WidgetSeverity
class HelloWorldPlugin(PluginBase):
"""Example plugin that demonstrates the plugin framework."""
def __init__(self):
"""Initialize the Hello World plugin."""
super().__init__()
self.counter = 0
async def on_load(self):
"""Called when the plugin is loaded."""
print("Hello World Plugin: Loaded!")
async def on_enable(self):
"""Called when the plugin is enabled."""
print("Hello World Plugin: Enabled!")
# Register a header widget to show plugin is active
self.register_widget(
widget_id="status",
severity=WidgetSeverity.SUCCESS,
message="Hello World plugin active",
icon="👋",
dismissible=True,
action_label="Settings",
action_url="/settings"
)
# Register a hook for PM3 commands
async def pm3_command_hook(command: str):
"""Hook that logs PM3 commands."""
self.counter += 1
print(f"Hello World Plugin: PM3 command #{self.counter}: {command}")
return {"plugin": "hello_world", "command_count": self.counter}
self.register_hook("pm3_command", pm3_command_hook)
# Register a hook for update checks
async def update_check_hook():
"""Hook that runs on update checks."""
print("Hello World Plugin: Update check performed")
return {"plugin": "hello_world", "message": "Hello from plugin!"}
self.register_hook("update_check", update_check_hook)
async def on_disable(self):
"""Called when the plugin is disabled."""
print(f"Hello World Plugin: Disabled! (processed {self.counter} commands)")
# Remove the header widget
self.unregister_widget("status")
async def on_unload(self):
"""Called when the plugin is unloaded."""
print("Hello World Plugin: Unloaded! Goodbye!")
def get_metadata(self) -> PluginMetadata:
"""Get plugin metadata."""
return self.metadata

View File

@@ -0,0 +1,10 @@
{
"id": "hello_world",
"name": "Hello World Plugin",
"version": "1.0.0",
"description": "Example plugin demonstrating the plugin framework",
"author": "Dangerous Pi Team",
"homepage": "https://github.com/yourusername/dangerous-pi",
"dependencies": [],
"permissions": []
}

View File

@@ -0,0 +1,23 @@
// Dangerous Pi polkit rules
// Allows the dangerous-pi user to manage NetworkManager and specific systemd services
// without requiring sudo
polkit.addRule(function(action, subject) {
// Allow NetworkManager control for dangerous-pi user
if (action.id.indexOf("org.freedesktop.NetworkManager") === 0 &&
subject.user === "__DANGEROUS_PI_USER__") {
return polkit.Result.YES;
}
// Allow managing specific systemd services
if (action.id === "org.freedesktop.systemd1.manage-units" &&
subject.user === "__DANGEROUS_PI_USER__") {
// Check if the unit is one of our allowed services
var unit = action.lookup("unit");
if (unit === "hostapd.service" ||
unit === "dnsmasq.service" ||
unit === "NetworkManager.service") {
return polkit.Result.YES;
}
}
});

View File

@@ -0,0 +1,13 @@
fastapi==0.115.0
uvicorn[standard]==0.32.0
python-multipart==0.0.12
aiosqlite==0.20.0
aiohttp==3.10.5
httpx==0.27.2
pydantic==2.9.0
pydantic-settings==2.5.0
psutil==6.1.0
smbus2==0.4.3
pyudev>=0.24.0
pyserial>=3.5
bless>=0.2.5

View File

@@ -0,0 +1,86 @@
#!/bin/bash
# Apply CPU cores defaults to cmdline.txt (fallback for manual installations)
# Used by dangerous-pi-cpu-cores.service
#
# NOTE: When using the official pi-gen image, maxcpus is already set during build.
# This script only runs as a fallback for manual installations.
#
# On Pi Zero 2 W (and other ARM devices), CPU hotplug via sysfs does NOT work.
# Instead, we use the maxcpus=N kernel parameter in cmdline.txt.
# This script sets the default maxcpus for Pi Zero 2 W if not already configured.
LOG_TAG="dangerous-pi-cpu-cores"
log() {
logger -t "$LOG_TAG" "$1"
echo "$1"
}
# Find cmdline.txt (location varies by Pi OS version)
find_cmdline() {
if [ -f /boot/firmware/cmdline.txt ]; then
echo "/boot/firmware/cmdline.txt"
elif [ -f /boot/cmdline.txt ]; then
echo "/boot/cmdline.txt"
else
echo ""
fi
}
CMDLINE_FILE=$(find_cmdline)
if [ -z "$CMDLINE_FILE" ]; then
log "ERROR: Could not find cmdline.txt"
exit 1
fi
# Check if maxcpus is already configured
if grep -q "maxcpus=" "$CMDLINE_FILE"; then
CURRENT=$(grep -o 'maxcpus=[0-9]*' "$CMDLINE_FILE" | cut -d= -f2)
log "maxcpus already configured: $CURRENT cores"
exit 0
fi
# Determine default cores based on Pi model
get_default_cores() {
local model=""
local total_cores=$(nproc --all)
if [ -f /proc/device-tree/model ]; then
model=$(cat /proc/device-tree/model | tr -d '\0')
fi
# Pi Zero 2 W has 4 cores but should default to 2 for thermal management
if echo "$model" | grep -q "Pi Zero 2"; then
echo 2
# Single-core Pis - no need to set maxcpus
elif echo "$model" | grep -q "Pi Zero W\|Pi Zero$\|Pi 1\|Pi Model A\|Pi Model B"; then
echo "$total_cores" # Return total (no restriction needed)
# All other multi-core Pis use all cores by default
else
echo "$total_cores"
fi
}
DEFAULT_CORES=$(get_default_cores)
TOTAL_CORES=$(nproc --all)
# Only set maxcpus if we want fewer than total cores
if [ "$DEFAULT_CORES" -lt "$TOTAL_CORES" ]; then
log "Setting default maxcpus=$DEFAULT_CORES for this device"
# Backup original
cp "$CMDLINE_FILE" "${CMDLINE_FILE}.bak"
# Append maxcpus to cmdline
CURRENT_CMDLINE=$(cat "$CMDLINE_FILE" | tr -d '\n')
echo "$CURRENT_CMDLINE maxcpus=$DEFAULT_CORES" > "${CMDLINE_FILE}.new"
mv "${CMDLINE_FILE}.new" "$CMDLINE_FILE"
log "Updated $CMDLINE_FILE with maxcpus=$DEFAULT_CORES"
log "NOTE: This will take effect on next reboot"
else
log "No maxcpus restriction needed (using all $TOTAL_CORES cores)"
fi
exit 0

View File

@@ -0,0 +1,198 @@
#!/bin/bash
# Audit build scripts for common issues
# Usage: ./scripts/audit-build-scripts.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Auditing Build Scripts ==="
echo ""
# Colors
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
WARNINGS=0
ERRORS=0
# Function to report issues
warn() {
echo -e "${YELLOW}WARNING:${NC} $1"
((WARNINGS++))
}
error() {
echo -e "${RED}ERROR:${NC} $1"
((ERRORS++))
}
ok() {
echo -e "${GREEN}${NC} $1"
}
echo "1. Checking for commands used before installation..."
echo "------------------------------------------------"
# Check PM3 stage
PM3_SCRIPT="$PROJECT_DIR/pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh"
if [ -f "$PM3_SCRIPT" ]; then
# Check if cmake is used before apt-get install
if grep -q "cmake" "$PM3_SCRIPT"; then
if ! grep -B50 "cmake" "$PM3_SCRIPT" | grep -q "apt-get install.*cmake"; then
warn "PM3: cmake used but may not be installed"
else
ok "PM3: cmake installation verified"
fi
fi
# Check for git before installation
if grep -q "git clone" "$PM3_SCRIPT"; then
if ! grep -B50 "git clone" "$PM3_SCRIPT" | grep -q "apt-get install.*git"; then
warn "PM3: git used but may not be installed"
else
ok "PM3: git installation verified"
fi
fi
fi
# Check DangerousPi stage
DTPI_SCRIPT="$PROJECT_DIR/pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh"
if [ -f "$DTPI_SCRIPT" ]; then
# Check for pip3
if grep -q "pip3 install" "$DTPI_SCRIPT"; then
if ! grep -B50 "pip3 install" "$DTPI_SCRIPT" | grep -q "apt-get install.*python3-pip\|command -v pip3"; then
error "DangerousPi: pip3 used but not installed"
else
ok "DangerousPi: pip3 installation check present"
fi
fi
fi
echo ""
echo "2. Checking for missing directory creation..."
echo "-------------------------------------------"
# Check WiFi AP script
WIFI_SCRIPT="$PROJECT_DIR/pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh"
if [ -f "$WIFI_SCRIPT" ]; then
# Check for directory writes
DIRS_WRITTEN=$(grep -n "cat >" "$WIFI_SCRIPT" | grep -oE '/etc/[^/]+(/[^/]+)*/' | sort -u)
for dir in $DIRS_WRITTEN; do
dir_parent=$(dirname "$dir")
if ! grep -q "mkdir -p $dir_parent\|mkdir -p $dir" "$WIFI_SCRIPT"; then
warn "WiFi: Writing to $dir without mkdir -p"
else
ok "WiFi: $dir directory creation verified"
fi
done
fi
echo ""
echo "3. Checking for undefined variables..."
echo "------------------------------------"
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run*.sh; do
if [ -f "$script" ]; then
stage=$(basename $(dirname $(dirname "$script")))
substage=$(basename $(dirname "$script"))
# Check for common undefined vars
if grep -q '\$ROOTFS_DIR' "$script" && [ "$(basename "$script")" != "00-run.sh" ]; then
warn "$stage/$substage: Uses \$ROOTFS_DIR in chroot script (should be in 00-run.sh)"
fi
fi
done
echo ""
echo "4. Checking for missing error handling..."
echo "---------------------------------------"
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run*.sh; do
if [ -f "$script" ]; then
stage=$(basename $(dirname $(dirname "$script")))
substage=$(basename $(dirname "$script"))
# Check if script has #!/bin/bash -e
if ! head -1 "$script" | grep -q "bash -e"; then
warn "$stage/$substage: Missing 'set -e' or '#!/bin/bash -e'"
else
ok "$stage/$substage: Error handling enabled"
fi
fi
done
echo ""
echo "5. Checking for sudo usage (not allowed in chroot)..."
echo "---------------------------------------------------"
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run*.sh; do
if [ -f "$script" ]; then
stage=$(basename $(dirname $(dirname "$script")))
substage=$(basename $(dirname "$script"))
if grep -q "sudo " "$script"; then
error "$stage/$substage: Contains 'sudo' commands (not allowed in chroot)"
fi
fi
done
ok "No sudo commands found in stage scripts"
echo ""
echo "6. Checking configuration consistency..."
echo "--------------------------------------"
CONFIG="$PROJECT_DIR/pi-gen/config"
if [ -f "$CONFIG" ]; then
USERNAME=$(grep "FIRST_USER_NAME=" "$CONFIG" | cut -d'"' -f2)
echo "Configured username: $USERNAME"
if [ "$USERNAME" != "pi" ]; then
warn "Username is '$USERNAME' (not standard 'pi')"
echo " This is OK if intentional, but verify all scripts handle it correctly"
else
ok "Using standard 'pi' username"
fi
# Check if QCOW2 is enabled
if grep -q "USE_QCOW2=1" "$CONFIG"; then
ok "QCOW2 caching enabled for fast rebuilds"
else
warn "QCOW2 caching disabled - rebuilds will be slow"
fi
fi
echo ""
echo "7. Checking for required EXPORT_IMAGE markers..."
echo "----------------------------------------------"
for stage in "$PROJECT_DIR"/pi-gen/stage{PM3,DangerousPi}; do
if [ -d "$stage" ]; then
stage_name=$(basename "$stage")
if [ -f "$stage/EXPORT_IMAGE" ]; then
ok "$stage_name: EXPORT_IMAGE present"
else
error "$stage_name: Missing EXPORT_IMAGE marker"
fi
fi
done
echo ""
echo "=== Audit Summary ==="
echo "Warnings: $WARNINGS"
echo "Errors: $ERRORS"
echo ""
if [ $ERRORS -gt 0 ]; then
echo -e "${RED}BUILD SCRIPTS HAVE ERRORS - REVIEW REQUIRED${NC}"
exit 1
elif [ $WARNINGS -gt 0 ]; then
echo -e "${YELLOW}BUILD SCRIPTS HAVE WARNINGS - REVIEW RECOMMENDED${NC}"
exit 0
else
echo -e "${GREEN}ALL CHECKS PASSED${NC}"
exit 0
fi

View File

@@ -0,0 +1,39 @@
#!/bin/bash
# Quick build status checker
# Usage: ./scripts/build-status.sh
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
if ! docker ps --filter name=pigen_work --format "{{.Status}}" &>/dev/null; then
echo -e "${RED}Docker not available${NC}"
exit 1
fi
STATUS=$(docker ps --filter name=pigen_work --format "{{.Status}}")
if [ -z "$STATUS" ]; then
echo -e "${YELLOW}No build running${NC}"
echo "Start a build with: ./build-image.sh full"
exit 0
fi
echo -e "${GREEN}Build Status:${NC} $STATUS"
echo ""
echo -e "${BLUE}Current Stage:${NC}"
docker logs pigen_work 2>&1 | grep -E "^\[.*\] Begin /pi-gen/(stage[^/]+)" | tail -1
echo ""
echo -e "${BLUE}Recent Progress (last 10 steps):${NC}"
docker logs pigen_work 2>&1 | grep -E "^\[.*\] (Begin|End)" | tail -10
echo ""
echo -e "${BLUE}Latest Activity:${NC}"
docker logs --tail 3 pigen_work 2>&1
echo ""
echo -e "Watch live: ${YELLOW}docker logs -f pigen_work${NC}"

View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Install udev rules for Proxmark3 device detection
# Run with sudo
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
UDEV_RULES_SRC="$PROJECT_ROOT/udev/77-dangerous-pi-pm3.rules"
UDEV_RULES_DEST="/etc/udev/rules.d/77-dangerous-pi-pm3.rules"
echo "Installing Proxmark3 udev rules for Dangerous Pi..."
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: This script must be run as root (use sudo)"
exit 1
fi
# Check if source file exists
if [ ! -f "$UDEV_RULES_SRC" ]; then
echo "ERROR: Source udev rules file not found: $UDEV_RULES_SRC"
exit 1
fi
# Copy rules file
echo "Copying udev rules to $UDEV_RULES_DEST..."
cp "$UDEV_RULES_SRC" "$UDEV_RULES_DEST"
chmod 644 "$UDEV_RULES_DEST"
# Reload udev rules
echo "Reloading udev rules..."
udevadm control --reload-rules
udevadm trigger
# Add user to plugdev group if not already a member
if ! groups $SUDO_USER | grep -q '\bplugdev\b'; then
echo "Adding user $SUDO_USER to plugdev group..."
usermod -a -G plugdev $SUDO_USER
echo "NOTE: User must log out and back in for group membership to take effect"
fi
echo ""
echo "✓ Udev rules installed successfully!"
echo ""
echo "The following rules are now active:"
echo " - Proxmark3 RDV4/Easy devices will be accessible at /dev/ttyACM*"
echo " - Devices will have permissions 0666 (readable/writable by all)"
echo " - Symlinks created: /dev/proxmark3-*"
echo " - Events logged to system logger"
echo ""
echo "To test, connect a Proxmark3 device and run:"
echo " ls -l /dev/ttyACM*"
echo " ls -l /dev/proxmark3-*"
echo ""

View File

@@ -0,0 +1,657 @@
#!/bin/bash
# Comprehensive Pre-Flight Validation for Dangerous Pi Builds
# Catches errors BEFORE wasting hours on failed builds
# Usage: ./scripts/preflight-check.sh
# Don't use set -e - we want to collect ALL errors, not stop on first one
set +e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PI_GEN_DIR="/home/work/pi-gen-builder"
# Colors
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
ERRORS=0
WARNINGS=0
CHECKS_PASSED=0
error() {
echo -e "${RED}✗ ERROR:${NC} $1"
((ERRORS++))
}
warn() {
echo -e "${YELLOW}⚠ WARNING:${NC} $1"
((WARNINGS++))
}
pass() {
echo -e "${GREEN}${NC} $1"
((CHECKS_PASSED++))
}
info() {
echo -e "${BLUE}${NC} $1"
}
section() {
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
}
echo ""
echo "╔════════════════════════════════════════════════════════════╗"
echo "║ DANGEROUS PI BUILD PRE-FLIGHT VALIDATION ║"
echo "╚════════════════════════════════════════════════════════════╝"
echo ""
# ============================================================================
section "1. PI-GEN ENVIRONMENT CHECKS"
# ============================================================================
# Check if pi-gen directory exists
if [ -d "$PI_GEN_DIR" ]; then
pass "Pi-gen directory exists: $PI_GEN_DIR"
else
error "Pi-gen directory not found: $PI_GEN_DIR"
fi
# Check Docker availability
if command -v docker &> /dev/null; then
if docker ps &> /dev/null; then
pass "Docker is available and accessible"
else
error "Docker command exists but cannot connect (check permissions)"
fi
else
error "Docker is not installed or not in PATH"
fi
# Check qemu registration
if [ -f "/proc/sys/fs/binfmt_misc/qemu-aarch64" ]; then
if grep -q "enabled" /proc/sys/fs/binfmt_misc/qemu-aarch64; then
pass "qemu-aarch64 is registered and enabled"
else
warn "qemu-aarch64 exists but may not be enabled"
fi
else
warn "qemu-aarch64 not registered (will be registered during build)"
fi
# ============================================================================
section "2. CONFIGURATION FILE VALIDATION"
# ============================================================================
CONFIG_FILE="$PROJECT_DIR/pi-gen/config"
if [ -f "$CONFIG_FILE" ]; then
pass "Config file exists: $CONFIG_FILE"
# Check required variables
for var in IMG_NAME RELEASE STAGE_LIST FIRST_USER_NAME; do
if grep -q "^$var=" "$CONFIG_FILE"; then
value=$(grep "^$var=" "$CONFIG_FILE" | cut -d'=' -f2- | tr -d '"')
pass "Config has $var=$value"
else
error "Config missing required variable: $var"
fi
done
# Check QCOW2 setting
if grep -q "USE_QCOW2=1" "$CONFIG_FILE"; then
pass "QCOW2 caching enabled (fast incremental builds)"
else
warn "QCOW2 caching disabled (slow rebuilds)"
fi
else
error "Config file not found: $CONFIG_FILE"
fi
# ============================================================================
section "3. STAGE STRUCTURE VALIDATION"
# ============================================================================
# Get list of custom stages from config
CUSTOM_STAGES=$(grep "^STAGE_LIST=" "$CONFIG_FILE" | cut -d'"' -f2 | grep -oE 'stage[A-Za-z0-9_]+' | grep -v '^stage[0-9]$')
for stage in $CUSTOM_STAGES; do
STAGE_DIR="$PROJECT_DIR/pi-gen/$stage"
if [ ! -d "$STAGE_DIR" ]; then
error "Stage directory does not exist: $STAGE_DIR"
continue
fi
info "Checking stage: $stage"
# Check for prerun.sh (CRITICAL - this was the bug!)
if [ -f "$STAGE_DIR/prerun.sh" ]; then
pass " Has prerun.sh"
# Check if prerun.sh contains copy_previous
if grep -q "copy_previous" "$STAGE_DIR/prerun.sh"; then
pass " prerun.sh calls copy_previous"
else
error " prerun.sh exists but doesn't call copy_previous"
info " This will cause: 'Unable to chroot' error"
fi
# Check if executable
if [ -x "$STAGE_DIR/prerun.sh" ]; then
pass " prerun.sh is executable"
else
warn " prerun.sh is not executable (should be chmod +x)"
fi
else
# Only error if this is not stage0 (stage0 doesn't need prerun.sh)
if [ "$stage" != "stage0" ]; then
error " MISSING prerun.sh (CRITICAL)"
info " This will cause: 'No such file or directory' for rootfs"
info " Fix: Create prerun.sh with copy_previous function"
fi
fi
# Check for EXPORT_IMAGE
if [ -f "$STAGE_DIR/EXPORT_IMAGE" ]; then
pass " Has EXPORT_IMAGE"
# Validate EXPORT_IMAGE syntax
if grep -q "IMG_SUFFIX" "$STAGE_DIR/EXPORT_IMAGE"; then
pass " EXPORT_IMAGE has IMG_SUFFIX"
else
warn " EXPORT_IMAGE missing IMG_SUFFIX"
fi
else
warn " No EXPORT_IMAGE (stage won't create image)"
fi
# Check for substages
substage_count=$(find "$STAGE_DIR" -maxdepth 1 -type d -name '[0-9][0-9]*' | wc -l)
if [ $substage_count -eq 0 ]; then
warn " No substages found (stage does nothing)"
else
pass " Found $substage_count substage(s)"
fi
done
# ============================================================================
section "4. BUILD SCRIPT SYNTAX VALIDATION"
# ============================================================================
info "Checking all stage scripts for syntax errors..."
for script in "$PROJECT_DIR"/pi-gen/stage*/*/[0-9][0-9]-*.sh; do
if [ -f "$script" ]; then
if bash -n "$script" 2>/dev/null; then
pass " $(basename $(dirname $(dirname "$script")))/$(basename $(dirname "$script"))/$(basename "$script")"
else
error " Syntax error in: $script"
bash -n "$script" 2>&1 | sed 's/^/ /'
fi
fi
done
# Check prerun.sh files
for script in "$PROJECT_DIR"/pi-gen/stage*/prerun.sh; do
if [ -f "$script" ]; then
if bash -n "$script" 2>/dev/null; then
pass " $(basename $(dirname "$script"))/prerun.sh"
else
error " Syntax error in: $script"
fi
fi
done
# ============================================================================
section "5. DEPENDENCY CHECKS IN SCRIPTS"
# ============================================================================
info "Checking if commands are installed before use..."
# Check PM3 stage for cmake before use
PM3_SCRIPT="$PROJECT_DIR/pi-gen/stage*/01-proxmark3/00-run-chroot.sh"
if ls $PM3_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
script="$1"
if grep -q "cmake" "$script"; then
if grep -B50 "cmake" "$script" | grep -q "apt-get install.*cmake"; then
echo "PASS"
else
echo "FAIL"
fi
else
echo "SKIP"
fi
' _ {} | grep -q "PASS"; then
pass " PM3: cmake installed before use"
elif ls $PM3_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
script="$1"
if grep -q "cmake" "$script"; then
if grep -B50 "cmake" "$script" | grep -q "apt-get install.*cmake"; then
echo "PASS"
else
echo "FAIL"
fi
else
echo "SKIP"
fi
' _ {} | grep -q "FAIL"; then
warn " PM3: cmake may not be installed before use"
fi
# Check dangerous-pi stage for pip3
DTPI_SCRIPT="$PROJECT_DIR/pi-gen/stage*/*/03-dangerous-pi/00-run-chroot.sh"
if ls $DTPI_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
script="$1"
if grep -q "pip3 install" "$script"; then
if grep -B20 "pip3 install" "$script" | grep -qE "apt-get install.*python3-pip|command -v pip3"; then
echo "PASS"
else
echo "FAIL"
fi
else
echo "SKIP"
fi
' _ {} | grep -q "PASS"; then
pass " Dangerous-Pi: pip3 check before use"
elif ls $DTPI_SCRIPT 2>/dev/null | head -1 | xargs -I {} bash -c '
script="$1"
if grep -q "pip3 install" "$script"; then
if grep -B20 "pip3 install" "$script" | grep -qE "apt-get install.*python3-pip|command -v pip3"; then
echo "PASS"
else
echo "FAIL"
fi
else
echo "SKIP"
fi
' _ {} | grep -q "FAIL"; then
error " Dangerous-Pi: pip3 used without installation check"
fi
# Check PM3 dependencies by analyzing source
info "Analyzing PM3 source for required dependencies..."
PM3_SCRIPT_PATH="$PROJECT_DIR/pi-gen/stagePM3/01-proxmark3/00-run-chroot.sh"
if [ -f "$PM3_SCRIPT_PATH" ]; then
# Clone PM3 temporarily to analyze (lightweight, just for analysis)
PM3_TEMP="/tmp/pm3-preflight-check"
if [ ! -d "$PM3_TEMP" ]; then
info "Cloning Proxmark3 for dependency analysis..."
git clone --depth 1 --quiet https://github.com/RfidResearchGroup/proxmark3 "$PM3_TEMP" 2>/dev/null
fi
if [ -d "$PM3_TEMP" ]; then
# Header to package mapping (common Debian packages)
declare -A HEADER_TO_PKG=(
["lz4frame.h"]="liblz4-dev"
["lz4.h"]="liblz4-dev"
["readline/readline.h"]="libreadline-dev"
["readline.h"]="libreadline-dev"
["libusb.h"]="libusb-1.0-0-dev"
["libusb-1.0/libusb.h"]="libusb-1.0-0-dev"
["pthread.h"]="libc6-dev"
["zlib.h"]="zlib1g-dev"
["bzlib.h"]="libbz2-dev"
["jansson.h"]="libjansson-dev"
["bluez/bluetooth.h"]="libbluetooth-dev"
)
# Find all #include statements for system headers
FOUND_HEADERS=$(grep -rh "^#include <" "$PM3_TEMP/client" 2>/dev/null | \
grep -oE '<[^>]+>' | tr -d '<>' | sort -u)
# Check if required packages are installed
for header in $FOUND_HEADERS; do
pkg="${HEADER_TO_PKG[$header]}"
if [ -n "$pkg" ]; then
# Check if this package is in our install script
if grep -q "$pkg" "$PM3_SCRIPT_PATH"; then
pass " PM3: $pkg (for $header)"
else
error " PM3: Missing $pkg (required for #include <$header>)"
info " Add '$pkg' to apt-get install in stagePM3/01-proxmark3/00-run-chroot.sh"
fi
fi
done
# Also check for essential build tools
ESSENTIAL_DEPS=("cmake" "build-essential" "git" "pkg-config")
for dep in "${ESSENTIAL_DEPS[@]}"; do
if grep -q "$dep" "$PM3_SCRIPT_PATH"; then
pass " PM3: $dep (build tool)"
else
error " PM3: Missing $dep (essential build tool)"
info " Add '$dep' to apt-get install in stagePM3/01-proxmark3/00-run-chroot.sh"
fi
done
else
warn " Could not clone PM3 for analysis - using basic checks only"
# Fallback to basic check
for dep in cmake build-essential git; do
if grep -q "$dep" "$PM3_SCRIPT_PATH"; then
pass " PM3: $dep"
else
error " PM3: Missing $dep"
fi
done
fi
fi
# ============================================================================
section "6. DIRECTORY CREATION VALIDATION"
# ============================================================================
info "Checking for mkdir -p before file writes to non-standard dirs..."
WIFI_SCRIPT="$PROJECT_DIR/pi-gen/stage*/*/01-Wireless-AP/00-run-chroot.sh"
if [ -f "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)" ]; then
# Check /etc/network/interfaces.d
if grep -q "cat.*>.*\/etc\/network\/interfaces.d" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
if grep -q "mkdir -p /etc/network/interfaces.d" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
pass " WiFi: /etc/network/interfaces.d created before use"
else
error " WiFi: /etc/network/interfaces.d not created (will fail)"
fi
fi
# Check lighttpd directories
if grep -q "cat.*>.*lighttpd" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
if grep -q "mkdir -p.*lighttpd.*conf-" "$(ls $WIFI_SCRIPT 2>/dev/null | head -1)"; then
pass " WiFi: lighttpd config directories created"
else
warn " WiFi: lighttpd directories may not exist"
fi
fi
fi
# ============================================================================
section "7. SOURCE FILE AVAILABILITY"
# ============================================================================
# Check if source files exist
for dir in app systemd scripts; do
if [ -d "$PROJECT_DIR/$dir" ]; then
pass "Source directory exists: $dir/"
else
error "Source directory missing: $dir/"
fi
done
if [ -f "$PROJECT_DIR/requirements.txt" ]; then
pass "requirements.txt exists"
else
error "requirements.txt missing"
fi
# ============================================================================
section "8. BUILD SCRIPT VALIDATION"
# ============================================================================
BUILD_SCRIPT="$PROJECT_DIR/build-image.sh"
if [ -f "$BUILD_SCRIPT" ]; then
pass "build-image.sh exists"
if [ -x "$BUILD_SCRIPT" ]; then
pass "build-image.sh is executable"
else
warn "build-image.sh is not executable"
fi
# Check if it uses our custom stages
if grep -q "stagePM3\|stageDangerousPi\|stage3\|stage4" "$BUILD_SCRIPT"; then
pass "build-image.sh references custom stages"
else
warn "build-image.sh may not reference custom stages"
fi
else
error "build-image.sh not found"
fi
# ============================================================================
section "9. PACKAGE NAME VALIDATION"
# ============================================================================
info "Validating package names against Debian repositories..."
# Create temporary container to query package database
TEMP_CONTAINER="preflight-pkg-check-$$"
docker run -d --name "$TEMP_CONTAINER" debian:trixie sleep 3600 > /dev/null 2>&1
docker exec "$TEMP_CONTAINER" apt-get update > /dev/null 2>&1
# Extract all package names from apt-get install commands
ALL_PACKAGES=$(grep -rh "apt-get install" "$PROJECT_DIR/pi-gen/stage"* 2>/dev/null | \
grep -v "^#" | \
sed 's/.*apt-get install//' | \
sed 's/-y//g; s/--no-install-recommends//g; s/--break-system-packages//g' | \
tr '\\' ' ' | \
tr '\n' ' ' | \
tr -s ' ' | \
grep -oE '[a-z0-9][a-z0-9+.-]+' | \
sort -u)
PACKAGE_ERRORS=0
for pkg in $ALL_PACKAGES; do
# Skip common flags, keywords, and commands
if echo "$pkg" | grep -qE '^(y|no|install|apt|get|update|upgrade|break|system|packages)$'; then
continue
fi
# Check if package exists in Debian repos
if docker exec "$TEMP_CONTAINER" apt-cache show "$pkg" > /dev/null 2>&1; then
pass " Package exists: $pkg"
else
error " Package NOT FOUND in Debian repos: $pkg"
info " Check for typos or hallucinated package names!"
((PACKAGE_ERRORS++))
fi
done
# Cleanup
docker rm -f "$TEMP_CONTAINER" > /dev/null 2>&1
if [ $PACKAGE_ERRORS -eq 0 ]; then
pass "All package names validated successfully"
else
error "Found $PACKAGE_ERRORS invalid package names"
info " Common mistakes: python-swig (should be: swig), libbluez-dev (should be: libbluetooth-dev)"
fi
# ============================================================================
section "10. FILE REFERENCE VALIDATION"
# ============================================================================
info "Checking for references to missing files..."
# Check for file copies in 00-run.sh scripts (outside chroot)
for script in "$PROJECT_DIR"/pi-gen/stage*/*/00-run.sh; do
if [ -f "$script" ]; then
STAGE_DIR=$(dirname "$script")
# Look for cp commands with source files
while read -r line; do
# Extract source path from cp commands
if echo "$line" | grep -q "^cp "; then
SRC=$(echo "$line" | awk '{print $2}' | sed 's/"//g')
# Skip if it's a flag, variable, or special path
if echo "$SRC" | grep -qE '^-|^\$|^\*|^/tmp|^/etc|^/var'; then
continue
fi
# Check if file exists relative to stage dir
if [ -f "$STAGE_DIR/$SRC" ] || [ -d "$STAGE_DIR/$SRC" ]; then
pass " Found: $SRC (in $(basename $(dirname "$script")))"
else
warn " Missing file reference: $SRC in $script"
fi
fi
done < <(grep -E "^cp " "$script" 2>/dev/null)
fi
done
# ============================================================================
section "11. SERVICE FILE SYNTAX VALIDATION"
# ============================================================================
info "Validating systemd service files..."
# Find all .service files in stage directories
for service_file in "$PROJECT_DIR"/pi-gen/stage*/*/*.service "$PROJECT_DIR"/systemd/*.service; do
if [ -f "$service_file" ]; then
BASENAME=$(basename "$service_file")
# Check for required sections
if grep -q "^\[Unit\]" "$service_file" && \
grep -q "^\[Service\]" "$service_file" && \
grep -q "^\[Install\]" "$service_file"; then
pass " Service file structure valid: $BASENAME"
else
error " Service file missing required sections: $BASENAME"
info " Required: [Unit], [Service], [Install]"
fi
# Check for ExecStart
if grep -q "^ExecStart=" "$service_file"; then
pass " Has ExecStart: $BASENAME"
else
error " Missing ExecStart in: $BASENAME"
fi
# Check for invalid __PLACEHOLDERS__ that weren't substituted
# Allow __DANGEROUS_PI_USER__ as it's intentionally substituted during build
UNSUBBED=$(grep "__.*__" "$service_file" 2>/dev/null | grep -v "__DANGEROUS_PI_USER__" || true)
if [ -n "$UNSUBBED" ]; then
warn " Found un-substituted placeholder in: $BASENAME"
echo "$UNSUBBED" | sed 's/^/ /'
fi
fi
done
# ============================================================================
section "12. ENVIRONMENT VARIABLE CHECKS"
# ============================================================================
info "Checking for undefined environment variables in scripts..."
for script in "$PROJECT_DIR"/pi-gen/stage*/*/[0-9][0-9]-*.sh; do
if [ -f "$script" ]; then
SCRIPT_NAME=$(basename "$script")
# Look for variable usage
USED_VARS=$(grep -oE '\$\{?[A-Z_][A-Z0-9_]*\}?' "$script" 2>/dev/null | \
sed 's/[${}]//g' | sort -u)
for var in $USED_VARS; do
# Check if variable is defined in script or is a known variable
if grep -q "^$var=" "$script" || \
grep -q "export $var=" "$script" || \
grep -q "$var=" "$script" || \
echo "$var" | grep -qE '^(ROOTFS_DIR|SCRIPT_DIR|DEFAULT_USER|DEFAULT_HOME|PATH|HOME|USER|HTTP|PM3_ROOT|BOOT_DIR|CONFIG_FILE|CMDLINE_FILE)$'; then
# Variable is defined, standard, or from external system (lighttpd config, PM3 build)
continue
else
warn " Potentially undefined variable: \$$var in $SCRIPT_NAME"
fi
done
fi
done
# ============================================================================
section "13. DISK SPACE CHECK"
# ============================================================================
info "Checking available disk space..."
REQUIRED_GB=15
AVAILABLE_KB=$(df /home/work/pi-gen-builder 2>/dev/null | tail -1 | awk '{print $4}')
AVAILABLE_GB=$((AVAILABLE_KB / 1024 / 1024))
if [ $AVAILABLE_GB -ge $REQUIRED_GB ]; then
pass "Sufficient disk space: ${AVAILABLE_GB}GB available (${REQUIRED_GB}GB required)"
else
error "Insufficient disk space: ${AVAILABLE_GB}GB available (${REQUIRED_GB}GB required)"
info " Pi-gen builds require ~15GB for image + cache"
info " Free up space or build will fail mid-download"
fi
# ============================================================================
section "14. NETWORK CONNECTIVITY CHECK"
# ============================================================================
info "Testing network connectivity to required repositories..."
# Test Debian repos
if curl -s --connect-timeout 5 http://deb.debian.org/debian/dists/trixie/Release > /dev/null 2>&1; then
pass "Can reach deb.debian.org"
else
error "Cannot reach deb.debian.org (Debian package repository)"
info " Check internet connection or firewall"
fi
# Test Raspberry Pi repos
if curl -s --connect-timeout 5 http://archive.raspberrypi.com/debian/dists/trixie/Release > /dev/null 2>&1; then
pass "Can reach archive.raspberrypi.com"
else
error "Cannot reach archive.raspberrypi.com (Raspberry Pi repository)"
fi
# Test GitHub (for PM3 clone)
if curl -s --connect-timeout 5 https://github.com > /dev/null 2>&1; then
pass "Can reach github.com"
else
error "Cannot reach github.com (needed for Proxmark3 clone)"
fi
# Test PM3 repo specifically
if git ls-remote https://github.com/RfidResearchGroup/proxmark3 > /dev/null 2>&1; then
pass "Proxmark3 repository is accessible"
else
error "Cannot access Proxmark3 repository"
info " URL: https://github.com/RfidResearchGroup/proxmark3"
fi
# ============================================================================
section "VALIDATION SUMMARY"
# ============================================================================
echo ""
echo "┌────────────────────────────────────────┐"
echo "│ VALIDATION RESULTS │"
echo "├────────────────────────────────────────┤"
printf "${GREEN}Checks Passed:${NC} %-4d │\n" $CHECKS_PASSED
printf "${YELLOW}Warnings:${NC} %-4d │\n" $WARNINGS
printf "${RED}Errors:${NC} %-4d │\n" $ERRORS
echo "└────────────────────────────────────────┘"
echo ""
if [ $ERRORS -gt 0 ]; then
echo -e "${RED}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ BUILD WILL FAIL - FIX ERRORS BEFORE ATTEMPTING BUILD ║${NC}"
echo -e "${RED}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo "Do NOT start a build until all errors are fixed."
echo "Each error listed above will cause build failure."
exit 1
elif [ $WARNINGS -gt 0 ]; then
echo -e "${YELLOW}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${YELLOW}║ WARNINGS DETECTED - REVIEW BEFORE BUILD ║${NC}"
echo -e "${YELLOW}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo "Warnings may indicate potential issues but won't necessarily fail the build."
exit 0
else
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ALL CHECKS PASSED - READY TO BUILD ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo "Build environment is validated. Safe to proceed with:"
echo " ./build-image.sh full"
exit 0
fi

View File

@@ -0,0 +1,114 @@
#!/bin/bash
# Script to resolve port conflict between Dangerous Pi and ttyd-bash
set -e
echo "Dangerous Pi Port Conflict Resolution"
echo "======================================"
echo ""
echo "The existing pi-pm3 setup uses:"
echo " - ttyd-bash on port 8000"
echo " - ttyd-pm3 on port 8080"
echo " - RaspAP on port 80"
echo ""
echo "Dangerous Pi wants to use port 8000 for the backend."
echo ""
echo "Choose a resolution option:"
echo " 1) Disable ttyd-bash (recommended - use Dangerous Pi's web terminal instead)"
echo " 2) Change Dangerous Pi to port 8001 (keep both services)"
echo " 3) Change ttyd-bash to port 8002 (keep both services)"
echo " 4) Cancel (manual configuration)"
echo ""
read -p "Enter option (1-4): " option
case $option in
1)
echo ""
echo "Disabling ttyd-bash service..."
if systemctl is-active --quiet ttyd-bash 2>/dev/null; then
sudo systemctl stop ttyd-bash
echo "✅ Stopped ttyd-bash"
fi
if systemctl is-enabled --quiet ttyd-bash 2>/dev/null; then
sudo systemctl disable ttyd-bash
echo "✅ Disabled ttyd-bash (won't start on boot)"
fi
echo ""
echo "✅ Done! Dangerous Pi can now use port 8000."
echo ""
echo "To re-enable ttyd-bash later:"
echo " sudo systemctl enable ttyd-bash"
echo " sudo systemctl start ttyd-bash"
;;
2)
echo ""
echo "Changing Dangerous Pi to port 8001..."
# Update environment file
if [ -f /opt/dangerous-pi/.env ]; then
if grep -q "^PORT=" /opt/dangerous-pi/.env; then
sudo sed -i 's/^PORT=.*/PORT=8001/' /opt/dangerous-pi/.env
else
echo "PORT=8001" | sudo tee -a /opt/dangerous-pi/.env
fi
echo "✅ Updated /opt/dangerous-pi/.env"
else
echo "⚠️ Environment file not found. Please edit manually."
fi
# Restart service if running
if systemctl is-active --quiet dangerous-pi 2>/dev/null; then
sudo systemctl restart dangerous-pi
echo "✅ Restarted dangerous-pi service"
fi
echo ""
echo "✅ Done! Dangerous Pi now uses port 8001."
echo " Access at: http://$(hostname -I | awk '{print $1}'):8001"
;;
3)
echo ""
echo "Changing ttyd-bash to port 8002..."
# Find and update ttyd-bash service file
if [ -f /etc/systemd/system/ttyd-bash.service ]; then
sudo sed -i 's/--port 8000/--port 8002/' /etc/systemd/system/ttyd-bash.service
sudo systemctl daemon-reload
echo "✅ Updated ttyd-bash service"
if systemctl is-active --quiet ttyd-bash 2>/dev/null; then
sudo systemctl restart ttyd-bash
echo "✅ Restarted ttyd-bash"
fi
echo ""
echo "✅ Done! ttyd-bash now uses port 8002."
echo " ttyd-bash: http://$(hostname -I | awk '{print $1}'):8002"
echo " Dangerous Pi can use port 8000"
else
echo "⚠️ ttyd-bash service file not found. Please configure manually."
fi
;;
4)
echo ""
echo "Cancelled. Manual configuration required."
echo ""
echo "Configuration files:"
echo " - Dangerous Pi: /opt/dangerous-pi/.env (PORT variable)"
echo " - ttyd-bash: /etc/systemd/system/ttyd-bash.service"
;;
*)
echo "Invalid option. Exiting."
exit 1
;;
esac
echo ""

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Reliable file transfer over serial console.
Bypasses screen to avoid escaping issues.
"""
import serial
import base64
import hashlib
import time
import sys
import re
def send_command(ser, cmd, wait=0.3):
"""Send a command and wait for output."""
ser.write(f"{cmd}\n".encode())
time.sleep(wait)
response = ser.read(ser.in_waiting or 1).decode('utf-8', errors='replace')
return response
def wait_for_prompt(ser, timeout=5):
"""Wait for shell prompt."""
start = time.time()
buffer = ""
while time.time() - start < timeout:
if ser.in_waiting:
buffer += ser.read(ser.in_waiting).decode('utf-8', errors='replace')
if buffer.rstrip().endswith('$') or buffer.rstrip().endswith('#'):
return buffer
time.sleep(0.1)
return buffer
def transfer_file(serial_port, local_file, remote_path, baud=115200):
"""Transfer a file reliably over serial."""
# Read local file
with open(local_file, 'rb') as f:
content = f.read()
# Compute checksums
local_md5 = hashlib.md5(content).hexdigest()
b64_content = base64.b64encode(content).decode()
b64_md5 = hashlib.md5(b64_content.encode()).hexdigest()
print(f"File size: {len(content)} bytes")
print(f"Base64 size: {len(b64_content)} bytes")
print(f"File MD5: {local_md5}")
print(f"Base64 MD5: {b64_md5}")
# Open serial port
print(f"\nOpening {serial_port}...")
ser = serial.Serial(serial_port, baud, timeout=1)
time.sleep(0.5)
# Send initial enter to get prompt
ser.write(b'\n')
time.sleep(0.5)
ser.read(ser.in_waiting) # Clear buffer
# Clear target file
print("Initializing remote file...")
send_command(ser, f"> /tmp/recv.b64", wait=0.5)
# Use very small chunks to avoid corruption
chunk_size = 60
chunks = [b64_content[i:i+chunk_size] for i in range(0, len(b64_content), chunk_size)]
print(f"Sending {len(chunks)} chunks...")
errors = 0
for i, chunk in enumerate(chunks):
# Use printf for better escaping than echo
cmd = f"printf '%s' '{chunk}' >> /tmp/recv.b64"
ser.write(f"{cmd}\n".encode())
time.sleep(0.15) # Small delay between chunks
# Read any response
if ser.in_waiting:
ser.read(ser.in_waiting)
if (i + 1) % 100 == 0:
print(f" Sent {i+1}/{len(chunks)} chunks ({100*(i+1)//len(chunks)}%)")
# Give Pi time to process
time.sleep(0.5)
print("All chunks sent. Waiting for completion...")
time.sleep(2)
# Verify base64 file checksum
print("\nVerifying base64 checksum...")
ser.read(ser.in_waiting) # Clear buffer
send_command(ser, f"md5sum /tmp/recv.b64", wait=1)
response = wait_for_prompt(ser, timeout=5)
print(f"Response: {response}")
if b64_md5 in response:
print("✓ Base64 checksum matches!")
else:
print("✗ Base64 checksum mismatch!")
# Extract the md5 from response
match = re.search(r'([a-f0-9]{32})', response)
if match:
print(f" Expected: {b64_md5}")
print(f" Got: {match.group(1)}")
return False
# Decode to target location
print(f"\nDecoding to {remote_path}...")
send_command(ser, f"base64 -d /tmp/recv.b64 > {remote_path}", wait=1)
time.sleep(1)
# Verify final file
print("Verifying final file...")
ser.read(ser.in_waiting)
send_command(ser, f"md5sum {remote_path}", wait=1)
response = wait_for_prompt(ser, timeout=5)
print(f"Response: {response}")
if local_md5 in response:
print("✓ File transfer successful!")
return True
else:
print("✗ Final file checksum mismatch!")
return False
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: serial_transfer.py <local_file> <remote_path> [serial_port]")
print("Example: serial_transfer.py wifi_manager.py /opt/dangerous-pi/app/backend/managers/wifi_manager.py")
sys.exit(1)
local_file = sys.argv[1]
remote_path = sys.argv[2]
serial_port = sys.argv[3] if len(sys.argv) > 3 else "/dev/ttyUSB1"
success = transfer_file(serial_port, local_file, remote_path)
sys.exit(0 if success else 1)

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