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:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!"
|
||||
@@ -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"
|
||||
@@ -1,4 +0,0 @@
|
||||
IMG_SUFFIX="-pm3"
|
||||
if [ "${USE_QEMU}" = "1" ]; then
|
||||
export IMG_SUFFIX="${IMG_SUFFIX}-qemu"
|
||||
fi
|
||||
@@ -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"
|
||||
95
pi-gen/stageDangerousPi/00-usb-console/00-run.sh
Executable file
95
pi-gen/stageDangerousPi/00-usb-console/00-run.sh
Executable 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"
|
||||
419
pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh
Normal file
419
pi-gen/stageDangerousPi/01-Wireless-AP/00-run-chroot.sh
Normal 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"
|
||||
@@ -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
|
||||
153
pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh
Executable file
153
pi-gen/stageDangerousPi/03-dangerous-pi/00-run-chroot.sh
Executable 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/"
|
||||
29
pi-gen/stageDangerousPi/03-dangerous-pi/00-run.sh
Executable file
29
pi-gen/stageDangerousPi/03-dangerous-pi/00-run.sh
Executable 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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Dangerous Pi application."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Business logic managers."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
"""Database models."""
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Background workers."""
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,{})}))});
|
||||
@@ -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};
|
||||
@@ -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"};
|
||||
@@ -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
8854
pi-gen/stageDangerousPi/03-dangerous-pi/files/app/frontend/package-lock.json
generated
Normal file
8854
pi-gen/stageDangerousPi/03-dangerous-pi/files/app/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
86
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/apply-cpu-cores.sh
Executable file
86
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/apply-cpu-cores.sh
Executable 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
|
||||
198
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/audit-build-scripts.sh
Executable file
198
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/audit-build-scripts.sh
Executable 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
|
||||
39
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/build-status.sh
Executable file
39
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/build-status.sh
Executable 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}"
|
||||
55
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/install-udev-rules.sh
Executable file
55
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/install-udev-rules.sh
Executable 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 ""
|
||||
657
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/preflight-check.sh
Executable file
657
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/preflight-check.sh
Executable 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
|
||||
114
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/resolve-port-conflict.sh
Executable file
114
pi-gen/stageDangerousPi/03-dangerous-pi/files/scripts/resolve-port-conflict.sh
Executable 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 ""
|
||||
@@ -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
Reference in New Issue
Block a user