Initial commit — FlipperDroid v0.1.0-poc
KernelSU module + Flipper Zero FAP that bridges both devices into a unified pentesting platform over USB CDC serial / BT rfcomm. Android side: bridge daemon, WebUI (:8089), bind mount namespace isolation stealth engine. Flipper side: proper FAP with 4-view GUI, GPIO/SubGHz/IR/file command handlers, async event streaming.
This commit is contained in:
62
META-INF/com/google/android/update-binary
Normal file
62
META-INF/com/google/android/update-binary
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/sbin/sh
|
||||
|
||||
#################
|
||||
# Initialization
|
||||
#################
|
||||
|
||||
umask 022
|
||||
|
||||
TMPDIR=/dev/tmp
|
||||
PERSISTDIR=/sbin/.magisk/mirror/persist
|
||||
|
||||
rm -rf $TMPDIR 2>/dev/null
|
||||
mkdir -p $TMPDIR
|
||||
|
||||
ui_print() { echo "$1"; }
|
||||
|
||||
require_new_magisk() {
|
||||
ui_print "*******************************"
|
||||
ui_print " Please install KernelSU-Next "
|
||||
ui_print "*******************************"
|
||||
exit 1
|
||||
}
|
||||
|
||||
##############
|
||||
# Environment
|
||||
##############
|
||||
|
||||
OUTFD=$2
|
||||
ZIPFILE=$3
|
||||
|
||||
mount /data 2>/dev/null
|
||||
|
||||
[ -f /data/adb/magisk/util_functions.sh ] || require_new_magisk
|
||||
. /data/adb/magisk/util_functions.sh
|
||||
[ $MAGISK_VER_CODE -lt 20400 ] && require_new_magisk
|
||||
|
||||
setup_flashable
|
||||
mount_partitions
|
||||
api_level_arch_detect
|
||||
|
||||
###############
|
||||
# Module Setup
|
||||
###############
|
||||
|
||||
MODID=flipperdroid
|
||||
MODPATH=$MOUNTPATH/$MODID
|
||||
|
||||
ui_print "- Extracting FlipperDroid module"
|
||||
unzip -o "$ZIPFILE" -x 'META-INF/*' -d $MODPATH >&2
|
||||
|
||||
# Default permissions
|
||||
set_perm_recursive $MODPATH 0 0 0755 0644
|
||||
set_perm $MODPATH/system/bin/flipperdroidd 0 2000 0755
|
||||
set_perm $MODPATH/system/bin/flipperdroid-webui 0 2000 0755
|
||||
set_perm $MODPATH/system/bin/fd-stealth 0 2000 0755
|
||||
set_perm $MODPATH/service.sh 0 0 0755
|
||||
set_perm $MODPATH/post-fs-data.sh 0 0 0755
|
||||
set_perm $MODPATH/uninstall.sh 0 0 0755
|
||||
|
||||
ui_print "- FlipperDroid installed"
|
||||
ui_print "- Connect Flipper Zero via USB or Bluetooth"
|
||||
ui_print "- WebUI available at http://localhost:8089"
|
||||
1
META-INF/com/google/android/updater-script
Normal file
1
META-INF/com/google/android/updater-script
Normal file
@@ -0,0 +1 @@
|
||||
#MAGISK
|
||||
116
README.md
Normal file
116
README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# FlipperDroid
|
||||
|
||||
**Status: Proof of Concept / Work in Progress**
|
||||
|
||||
FlipperDroid is a KernelSU-Next module that bridges a Flipper Zero and an Android phone into a unified pentesting platform. Both devices share resources — the phone gets full access to the Flipper's GPIO, SubGHz radio, NFC, RFID, IR, and iButton hardware, while the Flipper can offload compute-heavy tasks to the phone's CPU.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two daemons, one on each device, communicate over USB CDC serial (or Bluetooth rfcomm). A binary protocol handles command/response and async event streaming.
|
||||
|
||||
```
|
||||
┌─────────────────────┐ USB CDC / BT Serial ┌──────────────────────┐
|
||||
│ Android (Phone) │ ◄═══════════════════════════► │ Flipper Zero │
|
||||
│ │ │ │
|
||||
│ flipperdroidd │ Binary protocol v0.1 │ FlipperDroid Bridge │
|
||||
│ (bridge daemon) │ ─ Commands (phone→flipper) │ (FAP daemon) │
|
||||
│ │ ─ Responses │ │
|
||||
│ flipperdroid-webui │ ─ Async events (flipper→) │ Direct HAL access: │
|
||||
│ (WebUI :8089) │ ─ CPU offload requests │ ─ GPIO │
|
||||
│ │ │ ─ CC1101 (SubGHz) │
|
||||
│ fd-stealth │ │ ─ ST25R3916 (NFC) │
|
||||
│ (namespace isolat) │ │ ─ 125kHz RFID │
|
||||
│ │ │ ─ IR TX/RX │
|
||||
│ Exposes Flipper HW │ │ ─ iButton │
|
||||
│ as local resources │ │ ─ SD card storage │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
## What Works (PoC)
|
||||
|
||||
- USB device discovery (VID:PID 0483:5740)
|
||||
- Bluetooth fallback via rfcomm
|
||||
- Binary framed protocol with CRC8
|
||||
- GPIO read/write/init over bridge
|
||||
- SubGHz frequency set, TX, RX with async event streaming
|
||||
- IR transmit (NEC, Samsung, RC5, RC6, SIRC, Kaseikyo)
|
||||
- File operations on Flipper SD card
|
||||
- System status (battery, temp, uptime)
|
||||
- Connection monitoring with auto-reconnect
|
||||
- WebUI with tabs for GPIO, SubGHz, NFC/RFID, IR, Stealth, and live events
|
||||
- CPU sharing framework (Flipper offloads to phone)
|
||||
- Stealth via bind mount namespace isolation
|
||||
|
||||
## Stealth
|
||||
|
||||
FlipperDroid uses bind mount namespace isolation to remain invisible to the system. Nothing on the stock filesystem is modified — dm-verity passes, Play Integrity passes, banking apps see a stock device.
|
||||
|
||||
**How it works:**
|
||||
1. Stock files stay at their real paths, completely untouched.
|
||||
2. Our custom binaries and configs live in `/data/adb/modules/flipperdroid/stealth/`.
|
||||
3. Metadata (SELinux context, ownership, permissions, timestamps) is cloned from stock targets onto our files — `ls -Z` looks identical.
|
||||
4. Using `nsenter`, we enter specific process mount namespaces and bind-mount our files. Only that process sees the swap.
|
||||
5. Every other process on the system sees the untouched stock filesystem.
|
||||
|
||||
**Additional protections:**
|
||||
- WebUI port firewalled to localhost only via iptables
|
||||
- Config directory hidden with restrictive permissions
|
||||
- Nothing runs until user is logged in — no early boot traces
|
||||
- Configurable stealth map (`stealth_map.conf`) for per-process bind mount rules
|
||||
|
||||
**Usage:**
|
||||
```sh
|
||||
fd-stealth apply # Apply stealth map + hide device/port
|
||||
fd-stealth teardown # Remove all bind mounts
|
||||
fd-stealth status # Show active stealth state
|
||||
fd-stealth hide-dev # Quick: hide device + port + config
|
||||
fd-stealth show-dev # Quick: unhide everything
|
||||
```
|
||||
|
||||
## What's Planned
|
||||
|
||||
- Full NFC relay (card data relayed over phone's network)
|
||||
- RFID read/write/emulate via bridge
|
||||
- iButton operations
|
||||
- PWM and ADC over GPIO
|
||||
- BadUSB script execution via bridge
|
||||
- SubGHz signal recording/replay library
|
||||
- Custom Flipper firmware with optimized bridge daemon
|
||||
- Direct kernel driver for lower latency USB comms
|
||||
|
||||
## Requirements
|
||||
|
||||
**Android side:**
|
||||
- KernelSU-Next or Magisk
|
||||
- USB OTG support
|
||||
- Android 12+
|
||||
|
||||
**Flipper Zero side:**
|
||||
- Official firmware 0.90+ or compatible custom firmware
|
||||
- FlipperDroid Bridge FAP installed on SD card
|
||||
|
||||
## Installation
|
||||
|
||||
**Android:**
|
||||
Flash `FlipperDroid.zip` through KernelSU-Next module manager.
|
||||
|
||||
**Flipper Zero:**
|
||||
1. Clone the Flipper Zero firmware repo
|
||||
2. Copy `flipper/` contents to `applications_user/flipperdroid_bridge/`
|
||||
3. Build: `./fbt fap_flipperdroid_bridge`
|
||||
4. Copy the resulting `.fap` to Flipper SD: `apps/Tools/`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Connect Flipper Zero to phone via USB-C OTG cable
|
||||
2. Launch FlipperDroid Bridge app on Flipper Zero
|
||||
3. The Android daemon auto-detects and connects
|
||||
4. Open `http://localhost:8089` in a browser for the WebUI
|
||||
|
||||
## Protocol
|
||||
|
||||
See `system/etc/flipperdroid/protocol.md` for the full binary protocol specification.
|
||||
|
||||
## License
|
||||
|
||||
For authorized security research and penetration testing only.
|
||||
19
flipper/application.fam
Normal file
19
flipper/application.fam
Normal file
@@ -0,0 +1,19 @@
|
||||
App(
|
||||
appid="flipperdroid_bridge",
|
||||
name="FlipperDroid",
|
||||
apptype=FlipperAppType.EXTERNAL,
|
||||
entry_point="flipperdroid_app",
|
||||
requires=[
|
||||
"gui",
|
||||
"notification",
|
||||
"storage",
|
||||
],
|
||||
stack_size=8 * 1024,
|
||||
order=10,
|
||||
fap_icon="assets/flipperdroid_10px.png",
|
||||
fap_category="USB",
|
||||
fap_icon_assets="assets",
|
||||
fap_description="FlipperDroid Bridge — Fuse Flipper Zero with Android over USB. GPIO, SubGHz, IR, NFC, RFID, file transfer, CPU offload.",
|
||||
fap_author="snake",
|
||||
fap_version="0.1.0",
|
||||
)
|
||||
114
flipper/fd_app.h
Normal file
114
flipper/fd_app.h
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* FlipperDroid — application state
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <furi.h>
|
||||
#include <furi_hal.h>
|
||||
#include <gui/gui.h>
|
||||
#include <gui/view_port.h>
|
||||
#include <notification/notification.h>
|
||||
#include <notification/notification_messages.h>
|
||||
#include <storage/storage.h>
|
||||
#include "fd_protocol.h"
|
||||
|
||||
/* USB CDC channel — channel 0 is Flipper CLI, we use channel 1 */
|
||||
#define FD_CDC_CHANNEL 0
|
||||
|
||||
/* App views */
|
||||
typedef enum {
|
||||
FdViewStatus,
|
||||
FdViewGpio,
|
||||
FdViewSubghz,
|
||||
FdViewLog,
|
||||
} FdView;
|
||||
|
||||
/* GPIO pin entry */
|
||||
typedef struct {
|
||||
uint8_t id;
|
||||
const GpioPin* pin;
|
||||
const char* name;
|
||||
bool initialized;
|
||||
bool output;
|
||||
bool value;
|
||||
} FdGpioEntry;
|
||||
|
||||
/* Ring buffer for log messages */
|
||||
#define FD_LOG_LINES 8
|
||||
#define FD_LOG_COLS 30
|
||||
|
||||
typedef struct {
|
||||
char lines[FD_LOG_LINES][FD_LOG_COLS + 1];
|
||||
uint8_t head;
|
||||
uint8_t count;
|
||||
} FdLogBuffer;
|
||||
|
||||
/* Main application state */
|
||||
typedef struct {
|
||||
/* Furi */
|
||||
Gui* gui;
|
||||
ViewPort* view_port;
|
||||
FuriMessageQueue* input_queue;
|
||||
NotificationApp* notifications;
|
||||
FuriMutex* mutex;
|
||||
|
||||
/* Threads */
|
||||
FuriThread* rx_thread;
|
||||
FuriThread* event_thread;
|
||||
bool running;
|
||||
|
||||
/* Connection */
|
||||
bool connected;
|
||||
uint32_t rx_count;
|
||||
uint32_t tx_count;
|
||||
uint32_t err_count;
|
||||
uint32_t connect_tick;
|
||||
|
||||
/* View */
|
||||
FdView current_view;
|
||||
uint8_t selected_gpio;
|
||||
|
||||
/* SubGHz */
|
||||
bool subghz_rx_active;
|
||||
uint32_t subghz_freq;
|
||||
float subghz_last_rssi;
|
||||
|
||||
/* IR */
|
||||
bool ir_rx_active;
|
||||
|
||||
/* GPIO */
|
||||
FdGpioEntry gpio[12];
|
||||
uint8_t gpio_count;
|
||||
|
||||
/* Log */
|
||||
FdLogBuffer log;
|
||||
} FdApp;
|
||||
|
||||
/* Lifecycle */
|
||||
FdApp* fd_app_alloc(void);
|
||||
void fd_app_free(FdApp* app);
|
||||
int32_t fd_app_run(FdApp* app);
|
||||
|
||||
/* GUI callbacks */
|
||||
void fd_draw_callback(Canvas* canvas, void* ctx);
|
||||
void fd_input_callback(InputEvent* event, void* ctx);
|
||||
|
||||
/* Log */
|
||||
void fd_log(FdApp* app, const char* fmt, ...);
|
||||
|
||||
/* USB I/O */
|
||||
void fd_usb_tx(const uint8_t* data, uint16_t len);
|
||||
uint16_t fd_usb_rx(uint8_t* buf, uint16_t max_len, uint32_t timeout_ms);
|
||||
|
||||
/* Frame TX helpers */
|
||||
void fd_send_ok(FdApp* app, const uint8_t* data, uint16_t len);
|
||||
void fd_send_err(FdApp* app, FdError err, const char* msg);
|
||||
void fd_send_event(FdApp* app, FdEvent ev, const uint8_t* data, uint16_t len);
|
||||
|
||||
/* Command dispatch */
|
||||
void fd_handle_cmd(FdApp* app, uint8_t cmd, const uint8_t* payload, uint16_t len);
|
||||
|
||||
/* Workers */
|
||||
int32_t fd_rx_worker(void* ctx);
|
||||
int32_t fd_event_worker(void* ctx);
|
||||
526
flipper/fd_bridge.c
Normal file
526
flipper/fd_bridge.c
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* FlipperDroid Bridge — protocol handlers and USB I/O
|
||||
*
|
||||
* RX worker reads framed commands from USB CDC, dispatches to handlers.
|
||||
* Event worker pushes async events (SubGHz RX, GPIO, buttons) to phone.
|
||||
* All hardware access happens here.
|
||||
*/
|
||||
|
||||
#include "fd_app.h"
|
||||
#include <furi_hal_power.h>
|
||||
#include <furi_hal_infrared.h>
|
||||
#include <infrared.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#define TAG "FdBridge"
|
||||
|
||||
/* ---- USB CDC I/O ---- */
|
||||
|
||||
void fd_usb_tx(const uint8_t* data, uint16_t len) {
|
||||
furi_hal_cdc_send(FD_CDC_CHANNEL, (uint8_t*)data, len);
|
||||
}
|
||||
|
||||
uint16_t fd_usb_rx(uint8_t* buf, uint16_t max_len, uint32_t timeout_ms) {
|
||||
uint32_t start = furi_get_tick();
|
||||
uint16_t total = 0;
|
||||
|
||||
while(total < max_len) {
|
||||
int32_t n = furi_hal_cdc_receive(FD_CDC_CHANNEL, buf + total, max_len - total);
|
||||
if(n > 0) total += n;
|
||||
if(furi_get_tick() - start > timeout_ms) break;
|
||||
if(total >= max_len) break;
|
||||
furi_delay_ms(1);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/* ---- Frame TX ---- */
|
||||
|
||||
static void fd_tx_frame(FdApp* app, uint8_t resp_cmd, const uint8_t* payload, uint16_t plen) {
|
||||
uint16_t frame_len = 1 + plen; /* cmd + payload */
|
||||
uint8_t hdr[5] = {
|
||||
FD_MAGIC_HI, FD_MAGIC_LO,
|
||||
(frame_len >> 8) & 0xFF, frame_len & 0xFF,
|
||||
resp_cmd
|
||||
};
|
||||
|
||||
/* CRC over [len_hi, len_lo, cmd, payload...] */
|
||||
uint8_t crc_in[2 + 1 + FD_MAX_PAYLOAD];
|
||||
crc_in[0] = hdr[2];
|
||||
crc_in[1] = hdr[3];
|
||||
crc_in[2] = resp_cmd;
|
||||
if(payload && plen > 0) memcpy(crc_in + 3, payload, plen);
|
||||
uint8_t crc = fd_crc8(crc_in, 3 + plen);
|
||||
|
||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||
fd_usb_tx(hdr, 5);
|
||||
if(payload && plen > 0) fd_usb_tx(payload, plen);
|
||||
fd_usb_tx(&crc, 1);
|
||||
app->tx_count++;
|
||||
furi_mutex_release(app->mutex);
|
||||
}
|
||||
|
||||
void fd_send_ok(FdApp* app, const uint8_t* data, uint16_t len) {
|
||||
fd_tx_frame(app, FD_RESP_OK, data, len);
|
||||
}
|
||||
|
||||
void fd_send_err(FdApp* app, FdError err, const char* msg) {
|
||||
uint16_t mlen = msg ? strlen(msg) : 0;
|
||||
uint8_t buf[1 + 256];
|
||||
buf[0] = (uint8_t)err;
|
||||
if(msg && mlen > 0) memcpy(buf + 1, msg, mlen > 255 ? 255 : mlen);
|
||||
fd_tx_frame(app, FD_RESP_ERR, buf, 1 + (mlen > 255 ? 255 : mlen));
|
||||
app->err_count++;
|
||||
}
|
||||
|
||||
void fd_send_event(FdApp* app, FdEvent ev, const uint8_t* data, uint16_t len) {
|
||||
fd_tx_frame(app, (uint8_t)ev, data, len);
|
||||
}
|
||||
|
||||
/* ---- GPIO helpers ---- */
|
||||
|
||||
static FdGpioEntry* fd_find_gpio(FdApp* app, uint8_t pin_id) {
|
||||
for(uint8_t i = 0; i < app->gpio_count; i++) {
|
||||
if(app->gpio[i].id == pin_id) return &app->gpio[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- Command handlers ---- */
|
||||
|
||||
static void fd_cmd_system(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
|
||||
UNUSED(p);
|
||||
UNUSED(len);
|
||||
|
||||
switch(cmd) {
|
||||
case FdCmdPing:
|
||||
fd_send_ok(app, (const uint8_t*)"PONG", 4);
|
||||
fd_log(app, "PING -> PONG");
|
||||
if(!app->connected) {
|
||||
app->connected = true;
|
||||
app->connect_tick = furi_get_tick();
|
||||
notification_message(app->notifications, &sequence_blink_start_green);
|
||||
fd_log(app, "Android connected!");
|
||||
}
|
||||
break;
|
||||
|
||||
case FdCmdVersion: {
|
||||
char buf[64];
|
||||
int n = snprintf(buf, sizeof(buf), "%s v%s (%s)",
|
||||
FD_DEVICE_NAME, FD_VERSION,
|
||||
furi_hal_version_get_name_ptr() ? furi_hal_version_get_name_ptr() : "Flipper");
|
||||
fd_send_ok(app, (const uint8_t*)buf, n);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdCapabilities: {
|
||||
uint8_t caps = FD_CAP_GPIO | FD_CAP_SUBGHZ | FD_CAP_IR | FD_CAP_CPU;
|
||||
fd_send_ok(app, &caps, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdStatus: {
|
||||
uint8_t st[9];
|
||||
st[0] = furi_hal_power_get_pct();
|
||||
int16_t temp = (int16_t)(furi_hal_power_get_battery_temperature(FuriHalPowerICFuelGauge) * 10);
|
||||
st[1] = (temp >> 8) & 0xFF;
|
||||
st[2] = temp & 0xFF;
|
||||
uint32_t up = (furi_get_tick() - app->connect_tick) / 1000;
|
||||
st[3] = (up >> 24) & 0xFF;
|
||||
st[4] = (up >> 16) & 0xFF;
|
||||
st[5] = (up >> 8) & 0xFF;
|
||||
st[6] = up & 0xFF;
|
||||
st[7] = app->subghz_rx_active ? 1 : 0;
|
||||
st[8] = app->ir_rx_active ? 1 : 0;
|
||||
fd_send_ok(app, st, 9);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
fd_send_err(app, FdErrUnknownCmd, "bad sys cmd");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void fd_cmd_gpio(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
|
||||
switch(cmd) {
|
||||
case FdCmdGpioInit: {
|
||||
if(len < 2) { fd_send_err(app, FdErrInvalidParams, "need pin,mode"); return; }
|
||||
FdGpioEntry* g = fd_find_gpio(app, p[0]);
|
||||
if(!g) { fd_send_err(app, FdErrInvalidParams, "bad pin"); return; }
|
||||
|
||||
switch(p[1]) {
|
||||
case 0:
|
||||
furi_hal_gpio_init(g->pin, GpioModeInput, GpioPullNo, GpioSpeedLow);
|
||||
g->output = false;
|
||||
break;
|
||||
case 1:
|
||||
furi_hal_gpio_init(g->pin, GpioModeOutputPushPull, GpioPullNo, GpioSpeedVeryHigh);
|
||||
g->output = true;
|
||||
break;
|
||||
case 2:
|
||||
furi_hal_gpio_init(g->pin, GpioModeOutputOpenDrain, GpioPullNo, GpioSpeedVeryHigh);
|
||||
g->output = true;
|
||||
break;
|
||||
case 3:
|
||||
furi_hal_gpio_init(g->pin, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
|
||||
g->output = false;
|
||||
break;
|
||||
default:
|
||||
fd_send_err(app, FdErrInvalidParams, "bad mode");
|
||||
return;
|
||||
}
|
||||
g->initialized = true;
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "GPIO %s init mode %u", g->name, p[1]);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdGpioWrite: {
|
||||
if(len < 2) { fd_send_err(app, FdErrInvalidParams, "need pin,val"); return; }
|
||||
FdGpioEntry* g = fd_find_gpio(app, p[0]);
|
||||
if(!g) { fd_send_err(app, FdErrInvalidParams, "bad pin"); return; }
|
||||
if(!g->initialized) { fd_send_err(app, FdErrDisabled, "pin not init"); return; }
|
||||
furi_hal_gpio_write(g->pin, p[1] ? true : false);
|
||||
g->value = p[1] ? true : false;
|
||||
fd_send_ok(app, NULL, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdGpioRead: {
|
||||
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need pin"); return; }
|
||||
FdGpioEntry* g = fd_find_gpio(app, p[0]);
|
||||
if(!g) { fd_send_err(app, FdErrInvalidParams, "bad pin"); return; }
|
||||
if(!g->initialized) {
|
||||
/* Auto-init as input for convenience */
|
||||
furi_hal_gpio_init(g->pin, GpioModeInput, GpioPullNo, GpioSpeedLow);
|
||||
g->initialized = true;
|
||||
g->output = false;
|
||||
}
|
||||
uint8_t val = furi_hal_gpio_read(g->pin) ? 1 : 0;
|
||||
g->value = val;
|
||||
fd_send_ok(app, &val, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
fd_send_err(app, FdErrNotSupported, "gpio cmd n/a");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void fd_cmd_subghz(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
|
||||
switch(cmd) {
|
||||
case FdCmdSubghzSetFreq: {
|
||||
if(len < 4) { fd_send_err(app, FdErrInvalidParams, "need 4B freq"); return; }
|
||||
uint32_t freq = ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
|
||||
((uint32_t)p[2] << 8) | p[3];
|
||||
|
||||
bool valid = (freq >= 300000000 && freq <= 348000000) ||
|
||||
(freq >= 387000000 && freq <= 464000000) ||
|
||||
(freq >= 779000000 && freq <= 928000000);
|
||||
if(!valid) { fd_send_err(app, FdErrInvalidParams, "bad freq range"); return; }
|
||||
|
||||
app->subghz_freq = freq;
|
||||
furi_hal_subghz_idle();
|
||||
furi_hal_subghz_set_frequency(freq);
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "SubGHz freq=%lu", freq);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdSubghzTx: {
|
||||
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need data"); return; }
|
||||
if(app->subghz_freq == 0) { fd_send_err(app, FdErrInvalidParams, "set freq first"); return; }
|
||||
furi_hal_subghz_idle();
|
||||
furi_hal_subghz_set_frequency(app->subghz_freq);
|
||||
furi_hal_subghz_write_packet(p, len);
|
||||
furi_hal_subghz_tx();
|
||||
furi_delay_ms(100);
|
||||
furi_hal_subghz_idle();
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "SubGHz TX %u bytes", len);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdSubghzRxStart: {
|
||||
if(app->subghz_freq == 0) { fd_send_err(app, FdErrInvalidParams, "set freq first"); return; }
|
||||
furi_hal_subghz_idle();
|
||||
furi_hal_subghz_set_frequency(app->subghz_freq);
|
||||
furi_hal_subghz_rx();
|
||||
app->subghz_rx_active = true;
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "SubGHz RX start");
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdSubghzRxStop:
|
||||
furi_hal_subghz_idle();
|
||||
app->subghz_rx_active = false;
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "SubGHz RX stop");
|
||||
break;
|
||||
|
||||
case FdCmdSubghzGetRssi: {
|
||||
float rssi = furi_hal_subghz_get_rssi();
|
||||
app->subghz_last_rssi = rssi;
|
||||
int16_t ri = (int16_t)(rssi * 10);
|
||||
uint8_t rb[2] = {(ri >> 8) & 0xFF, ri & 0xFF};
|
||||
fd_send_ok(app, rb, 2);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
fd_send_err(app, FdErrNotSupported, "subghz cmd n/a");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void fd_cmd_ir(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
|
||||
switch(cmd) {
|
||||
case FdCmdIrTx: {
|
||||
if(len < 9) { fd_send_err(app, FdErrInvalidParams, "need 9B"); return; }
|
||||
uint32_t addr = ((uint32_t)p[1] << 24) | ((uint32_t)p[2] << 16) |
|
||||
((uint32_t)p[3] << 8) | p[4];
|
||||
uint32_t command = ((uint32_t)p[5] << 24) | ((uint32_t)p[6] << 16) |
|
||||
((uint32_t)p[7] << 8) | p[8];
|
||||
|
||||
InfraredMessage msg = {
|
||||
.protocol = p[0],
|
||||
.address = addr,
|
||||
.command = command,
|
||||
.repeat = false,
|
||||
};
|
||||
infrared_send(&msg, 1);
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "IR TX proto=%u", p[0]);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdIrRxStart:
|
||||
app->ir_rx_active = true;
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "IR RX start");
|
||||
break;
|
||||
|
||||
case FdCmdIrRxStop:
|
||||
app->ir_rx_active = false;
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "IR RX stop");
|
||||
break;
|
||||
|
||||
default:
|
||||
fd_send_err(app, FdErrNotSupported, "ir cmd n/a");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void fd_cmd_file(FdApp* app, uint8_t cmd, const uint8_t* p, uint16_t len) {
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
|
||||
switch(cmd) {
|
||||
case FdCmdFileList: {
|
||||
char path[256] = "/ext";
|
||||
if(len > 0 && len < sizeof(path)) {
|
||||
memcpy(path, p, len);
|
||||
path[len] = '\0';
|
||||
}
|
||||
|
||||
File* dir = storage_file_alloc(storage);
|
||||
if(!storage_dir_open(dir, path)) {
|
||||
fd_send_err(app, FdErrHardware, "open dir fail");
|
||||
storage_file_free(dir);
|
||||
break;
|
||||
}
|
||||
|
||||
char name[128];
|
||||
FileInfo info;
|
||||
uint8_t resp[FD_MAX_PAYLOAD];
|
||||
uint16_t pos = 0;
|
||||
|
||||
while(storage_dir_read(dir, &info, name, sizeof(name))) {
|
||||
uint16_t nlen = strlen(name);
|
||||
if(pos + nlen + 3 > sizeof(resp)) break;
|
||||
resp[pos++] = (info.flags & FSF_DIRECTORY) ? 'd' : 'f';
|
||||
resp[pos++] = ',';
|
||||
memcpy(resp + pos, name, nlen);
|
||||
pos += nlen;
|
||||
resp[pos++] = '\n';
|
||||
}
|
||||
storage_dir_close(dir);
|
||||
storage_file_free(dir);
|
||||
fd_send_ok(app, resp, pos);
|
||||
fd_log(app, "ls %s -> %u entries", path, pos);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdFileRead: {
|
||||
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need path"); break; }
|
||||
char path[256];
|
||||
uint16_t pl = (len < 255) ? len : 255;
|
||||
memcpy(path, p, pl);
|
||||
path[pl] = '\0';
|
||||
|
||||
File* file = storage_file_alloc(storage);
|
||||
if(!storage_file_open(file, path, FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
fd_send_err(app, FdErrHardware, "open fail");
|
||||
storage_file_free(file);
|
||||
break;
|
||||
}
|
||||
uint8_t buf[FD_MAX_PAYLOAD];
|
||||
uint16_t rd = storage_file_read(file, buf, sizeof(buf));
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
fd_send_ok(app, buf, rd);
|
||||
fd_log(app, "read %s %uB", path, rd);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdFileWrite: {
|
||||
if(len < 3) { fd_send_err(app, FdErrInvalidParams, "need plen+path+data"); break; }
|
||||
uint16_t plen_f = ((uint16_t)p[0] << 8) | p[1];
|
||||
if(2 + plen_f > len) { fd_send_err(app, FdErrInvalidParams, "bad plen"); break; }
|
||||
|
||||
char path[256];
|
||||
uint16_t cpy = (plen_f < 255) ? plen_f : 255;
|
||||
memcpy(path, p + 2, cpy);
|
||||
path[cpy] = '\0';
|
||||
|
||||
File* file = storage_file_alloc(storage);
|
||||
if(!storage_file_open(file, path, FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
|
||||
fd_send_err(app, FdErrHardware, "create fail");
|
||||
storage_file_free(file);
|
||||
break;
|
||||
}
|
||||
uint16_t dlen = len - 2 - plen_f;
|
||||
storage_file_write(file, p + 2 + plen_f, dlen);
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
fd_send_ok(app, NULL, 0);
|
||||
fd_log(app, "write %s %uB", path, dlen);
|
||||
break;
|
||||
}
|
||||
|
||||
case FdCmdFileDelete: {
|
||||
if(len < 1) { fd_send_err(app, FdErrInvalidParams, "need path"); break; }
|
||||
char path[256];
|
||||
uint16_t pl = (len < 255) ? len : 255;
|
||||
memcpy(path, p, pl);
|
||||
path[pl] = '\0';
|
||||
if(storage_simply_remove(storage, path))
|
||||
fd_send_ok(app, NULL, 0);
|
||||
else
|
||||
fd_send_err(app, FdErrHardware, "delete fail");
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
fd_send_err(app, FdErrUnknownCmd, "bad file cmd");
|
||||
break;
|
||||
}
|
||||
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
}
|
||||
|
||||
/* ---- Command dispatch ---- */
|
||||
|
||||
void fd_handle_cmd(FdApp* app, uint8_t cmd, const uint8_t* payload, uint16_t len) {
|
||||
FURI_LOG_D(TAG, "CMD 0x%02X len=%u", cmd, len);
|
||||
app->rx_count++;
|
||||
|
||||
if(cmd <= 0x04) fd_cmd_system(app, cmd, payload, len);
|
||||
else if(cmd <= 0x15) fd_cmd_gpio(app, cmd, payload, len);
|
||||
else if(cmd <= 0x26) fd_cmd_subghz(app, cmd, payload, len);
|
||||
else if(cmd >= 0x50 && cmd <= 0x54) fd_cmd_ir(app, cmd, payload, len);
|
||||
else if(cmd >= 0x90 && cmd <= 0x93) fd_cmd_file(app, cmd, payload, len);
|
||||
else fd_send_err(app, FdErrUnknownCmd, "unknown");
|
||||
}
|
||||
|
||||
/* ---- RX worker ---- */
|
||||
|
||||
int32_t fd_rx_worker(void* ctx) {
|
||||
FdApp* app = (FdApp*)ctx;
|
||||
FURI_LOG_I(TAG, "RX worker started");
|
||||
|
||||
while(app->running) {
|
||||
/* Scan for magic bytes */
|
||||
uint8_t magic[2];
|
||||
if(fd_usb_rx(magic, 2, 100) < 2) continue;
|
||||
if(magic[0] != FD_MAGIC_HI || magic[1] != FD_MAGIC_LO) continue;
|
||||
|
||||
/* Read frame length */
|
||||
uint8_t lb[2];
|
||||
if(fd_usb_rx(lb, 2, 500) < 2) continue;
|
||||
uint16_t flen = ((uint16_t)lb[0] << 8) | lb[1];
|
||||
if(flen > FD_MAX_PAYLOAD + 1) {
|
||||
FURI_LOG_W(TAG, "Frame too big: %u", flen);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Read cmd + payload + crc */
|
||||
uint8_t frame[FD_MAX_PAYLOAD + 2];
|
||||
uint16_t got = fd_usb_rx(frame, flen + 1, 1000);
|
||||
if(got < flen + 1) {
|
||||
FURI_LOG_W(TAG, "Short frame: %u/%u", got, flen + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Verify CRC */
|
||||
uint8_t crc_in[2 + FD_MAX_PAYLOAD + 1];
|
||||
crc_in[0] = lb[0];
|
||||
crc_in[1] = lb[1];
|
||||
memcpy(crc_in + 2, frame, flen);
|
||||
uint8_t expected = fd_crc8(crc_in, 2 + flen);
|
||||
if(expected != frame[flen]) {
|
||||
FURI_LOG_W(TAG, "CRC fail: %02X vs %02X", frame[flen], expected);
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Dispatch */
|
||||
fd_handle_cmd(app, frame[0], frame + 1, flen - 1);
|
||||
notification_message(app->notifications, &sequence_blink_cyan_10);
|
||||
}
|
||||
|
||||
FURI_LOG_I(TAG, "RX worker stopped");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Event worker ---- */
|
||||
|
||||
int32_t fd_event_worker(void* ctx) {
|
||||
FdApp* app = (FdApp*)ctx;
|
||||
FURI_LOG_I(TAG, "Event worker started");
|
||||
|
||||
while(app->running) {
|
||||
/* SubGHz RX check */
|
||||
if(app->subghz_rx_active) {
|
||||
if(furi_hal_subghz_is_rx_data_crc_valid()) {
|
||||
uint8_t pkt[64];
|
||||
furi_hal_subghz_read_packet(pkt, sizeof(pkt));
|
||||
float rssi = furi_hal_subghz_get_rssi();
|
||||
app->subghz_last_rssi = rssi;
|
||||
|
||||
/* Pack: data(64) + rssi_x10(2) + freq(4) */
|
||||
uint8_t ev[70];
|
||||
memcpy(ev, pkt, 64);
|
||||
int16_t ri = (int16_t)(rssi * 10);
|
||||
ev[64] = (ri >> 8) & 0xFF;
|
||||
ev[65] = ri & 0xFF;
|
||||
ev[66] = (app->subghz_freq >> 24) & 0xFF;
|
||||
ev[67] = (app->subghz_freq >> 16) & 0xFF;
|
||||
ev[68] = (app->subghz_freq >> 8) & 0xFF;
|
||||
ev[69] = app->subghz_freq & 0xFF;
|
||||
|
||||
fd_send_event(app, FdEventSubghzRx, ev, 70);
|
||||
notification_message(app->notifications, &sequence_blink_green_10);
|
||||
fd_log(app, "SubGHz RX rssi=%.1f", (double)rssi);
|
||||
}
|
||||
}
|
||||
|
||||
furi_delay_ms(10);
|
||||
}
|
||||
|
||||
FURI_LOG_I(TAG, "Event worker stopped");
|
||||
return 0;
|
||||
}
|
||||
90
flipper/fd_protocol.h
Normal file
90
flipper/fd_protocol.h
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* FlipperDroid Bridge Protocol v0.1
|
||||
* Shared definitions between all source files.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Wire format: [MAGIC_HI][MAGIC_LO][LEN_HI][LEN_LO][CMD][PAYLOAD...][CRC8] */
|
||||
#define FD_MAGIC_HI 0xFD
|
||||
#define FD_MAGIC_LO 0x01
|
||||
#define FD_MAX_PAYLOAD 2048
|
||||
#define FD_VERSION "0.1.0"
|
||||
#define FD_DEVICE_NAME "FlipperDroid"
|
||||
|
||||
/* ---- Commands (Phone -> Flipper) ---- */
|
||||
typedef enum {
|
||||
FdCmdPing = 0x01,
|
||||
FdCmdVersion = 0x02,
|
||||
FdCmdCapabilities = 0x03,
|
||||
FdCmdStatus = 0x04,
|
||||
|
||||
FdCmdGpioInit = 0x10,
|
||||
FdCmdGpioWrite = 0x11,
|
||||
FdCmdGpioRead = 0x12,
|
||||
FdCmdGpioPwm = 0x13,
|
||||
FdCmdGpioAdcRead = 0x14,
|
||||
|
||||
FdCmdSubghzSetFreq = 0x20,
|
||||
FdCmdSubghzTx = 0x21,
|
||||
FdCmdSubghzRxStart = 0x22,
|
||||
FdCmdSubghzRxStop = 0x23,
|
||||
FdCmdSubghzGetRssi = 0x24,
|
||||
|
||||
FdCmdIrTx = 0x50,
|
||||
FdCmdIrTxRaw = 0x51,
|
||||
FdCmdIrRxStart = 0x52,
|
||||
FdCmdIrRxStop = 0x53,
|
||||
|
||||
FdCmdFileList = 0x90,
|
||||
FdCmdFileRead = 0x91,
|
||||
FdCmdFileWrite = 0x92,
|
||||
FdCmdFileDelete = 0x93,
|
||||
} FdCommand;
|
||||
|
||||
/* ---- Events (Flipper -> Phone, async push) ---- */
|
||||
typedef enum {
|
||||
FdEventGpioIrq = 0xA0,
|
||||
FdEventSubghzRx = 0xA1,
|
||||
FdEventIrRx = 0xA2,
|
||||
FdEventButton = 0xA4,
|
||||
FdEventCpuReq = 0xA5,
|
||||
} FdEvent;
|
||||
|
||||
/* Response / Error */
|
||||
#define FD_RESP_OK 0xFE
|
||||
#define FD_RESP_ERR 0xFF
|
||||
|
||||
typedef enum {
|
||||
FdErrUnknownCmd = 0x01,
|
||||
FdErrInvalidParams = 0x02,
|
||||
FdErrDisabled = 0x03,
|
||||
FdErrHardware = 0x04,
|
||||
FdErrBusy = 0x05,
|
||||
FdErrTimeout = 0x06,
|
||||
FdErrNotSupported = 0x07,
|
||||
} FdError;
|
||||
|
||||
/* Capability bitmask reported by 0x03 */
|
||||
#define FD_CAP_GPIO (1 << 0)
|
||||
#define FD_CAP_SUBGHZ (1 << 1)
|
||||
#define FD_CAP_RFID (1 << 2)
|
||||
#define FD_CAP_NFC (1 << 3)
|
||||
#define FD_CAP_IR (1 << 4)
|
||||
#define FD_CAP_IBUTTON (1 << 5)
|
||||
#define FD_CAP_BADUSB (1 << 6)
|
||||
#define FD_CAP_CPU (1 << 7)
|
||||
|
||||
/* CRC8 Dallas/Maxim 0x31 */
|
||||
static inline uint8_t fd_crc8(const uint8_t* data, uint16_t len) {
|
||||
uint8_t crc = 0;
|
||||
for(uint16_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for(uint8_t j = 0; j < 8; j++)
|
||||
crc = (crc & 0x80) ? ((crc << 1) ^ 0x31) : (crc << 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
351
flipper/flipperdroid_app.c
Normal file
351
flipper/flipperdroid_app.c
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* FlipperDroid — main application entry point
|
||||
* Handles GUI, input, lifecycle. Workers in fd_bridge.c.
|
||||
*/
|
||||
|
||||
#include "fd_app.h"
|
||||
#include <stdio.h>
|
||||
#include <stdarg.h>
|
||||
|
||||
#define TAG "FlipperDroid"
|
||||
|
||||
/* ---- GPIO pin table ---- */
|
||||
static const struct {
|
||||
uint8_t id;
|
||||
const GpioPin* pin;
|
||||
const char* name;
|
||||
} gpio_defs[] = {
|
||||
{2, &gpio_ext_pa2, "A2"},
|
||||
{3, &gpio_ext_pa3, "A3"},
|
||||
{4, &gpio_ext_pa4, "A4"},
|
||||
{6, &gpio_ext_pa6, "A6"},
|
||||
{7, &gpio_ext_pa7, "A7"},
|
||||
{13, &gpio_ext_pb2, "B2"},
|
||||
{14, &gpio_ext_pb3, "B3"},
|
||||
{15, &gpio_ext_pb13, "B13"},
|
||||
{16, &gpio_ext_pb14, "B14"},
|
||||
{17, &gpio_ext_pc0, "C0"},
|
||||
{18, &gpio_ext_pc1, "C1"},
|
||||
{19, &gpio_ext_pc3, "C3"},
|
||||
};
|
||||
#define GPIO_DEF_COUNT (sizeof(gpio_defs) / sizeof(gpio_defs[0]))
|
||||
|
||||
/* ---- Logging ---- */
|
||||
void fd_log(FdApp* app, const char* fmt, ...) {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
char buf[FD_LOG_COLS + 1];
|
||||
vsnprintf(buf, sizeof(buf), fmt, args);
|
||||
va_end(args);
|
||||
|
||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||
uint8_t idx = (app->log.head + app->log.count) % FD_LOG_LINES;
|
||||
if(app->log.count < FD_LOG_LINES) {
|
||||
app->log.count++;
|
||||
} else {
|
||||
app->log.head = (app->log.head + 1) % FD_LOG_LINES;
|
||||
}
|
||||
strncpy(app->log.lines[idx], buf, FD_LOG_COLS);
|
||||
app->log.lines[idx][FD_LOG_COLS] = '\0';
|
||||
furi_mutex_release(app->mutex);
|
||||
|
||||
FURI_LOG_I(TAG, "%s", buf);
|
||||
}
|
||||
|
||||
/* ---- Draw ---- */
|
||||
void fd_draw_callback(Canvas* canvas, void* ctx) {
|
||||
FdApp* app = (FdApp*)ctx;
|
||||
furi_mutex_acquire(app->mutex, FuriWaitForever);
|
||||
|
||||
canvas_clear(canvas);
|
||||
canvas_set_font(canvas, FontPrimary);
|
||||
|
||||
switch(app->current_view) {
|
||||
case FdViewStatus: {
|
||||
/* Title bar */
|
||||
canvas_draw_str(canvas, 0, 10, "FlipperDroid");
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
|
||||
/* Connection status */
|
||||
if(app->connected) {
|
||||
canvas_draw_str(canvas, 72, 10, "[LINKED]");
|
||||
} else {
|
||||
canvas_draw_str(canvas, 68, 10, "[WAITING]");
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
char buf[40];
|
||||
canvas_draw_str(canvas, 0, 22, "Status:");
|
||||
canvas_draw_str(canvas, 44, 22, app->connected ? "Connected" : "Listening on USB");
|
||||
|
||||
snprintf(buf, sizeof(buf), "RX: %lu TX: %lu ERR: %lu",
|
||||
app->rx_count, app->tx_count, app->err_count);
|
||||
canvas_draw_str(canvas, 0, 32, buf);
|
||||
|
||||
/* Subsystem status */
|
||||
snprintf(buf, sizeof(buf), "SubGHz: %s %lu Hz",
|
||||
app->subghz_rx_active ? "RX" : "idle",
|
||||
app->subghz_freq);
|
||||
canvas_draw_str(canvas, 0, 42, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "IR: %s GPIO: %u pins",
|
||||
app->ir_rx_active ? "RX" : "idle", app->gpio_count);
|
||||
canvas_draw_str(canvas, 0, 52, buf);
|
||||
|
||||
/* Nav hint */
|
||||
canvas_draw_str(canvas, 0, 63, "<GPIO SubGHz> OK:Log Bk:Exit");
|
||||
break;
|
||||
}
|
||||
|
||||
case FdViewGpio: {
|
||||
canvas_draw_str(canvas, 0, 10, "GPIO Pins");
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
|
||||
for(uint8_t i = 0; i < app->gpio_count && i < 12; i++) {
|
||||
uint8_t row = i / 4;
|
||||
uint8_t col = i % 4;
|
||||
uint8_t x = col * 32;
|
||||
uint8_t y = 20 + row * 14;
|
||||
|
||||
char buf[12];
|
||||
FdGpioEntry* g = &app->gpio[i];
|
||||
|
||||
if(i == app->selected_gpio) {
|
||||
canvas_draw_box(canvas, x, y - 8, 30, 12);
|
||||
canvas_set_color(canvas, ColorWhite);
|
||||
}
|
||||
|
||||
if(g->initialized) {
|
||||
snprintf(buf, sizeof(buf), "%s:%c", g->name, g->value ? 'H' : 'L');
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%s:--", g->name);
|
||||
}
|
||||
canvas_draw_str(canvas, x + 1, y, buf);
|
||||
canvas_set_color(canvas, ColorBlack);
|
||||
}
|
||||
|
||||
canvas_draw_str(canvas, 0, 63, "<Stat ^v:Sel OK:Read >SG");
|
||||
break;
|
||||
}
|
||||
|
||||
case FdViewSubghz: {
|
||||
canvas_draw_str(canvas, 0, 10, "Sub-GHz");
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
|
||||
char buf[40];
|
||||
snprintf(buf, sizeof(buf), "Freq: %lu Hz", app->subghz_freq);
|
||||
canvas_draw_str(canvas, 0, 22, buf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "RSSI: %.1f dBm", (double)app->subghz_last_rssi);
|
||||
canvas_draw_str(canvas, 0, 32, buf);
|
||||
|
||||
canvas_draw_str(canvas, 0, 42, app->subghz_rx_active ? "State: RECEIVING" : "State: IDLE");
|
||||
|
||||
snprintf(buf, sizeof(buf), "Packets: %lu", app->rx_count);
|
||||
canvas_draw_str(canvas, 0, 52, buf);
|
||||
|
||||
canvas_draw_str(canvas, 0, 63, "<GPIO OK:RX Tog >Log");
|
||||
break;
|
||||
}
|
||||
|
||||
case FdViewLog: {
|
||||
canvas_draw_str(canvas, 0, 10, "Log");
|
||||
canvas_set_font(canvas, FontKeyboard);
|
||||
|
||||
for(uint8_t i = 0; i < app->log.count && i < 6; i++) {
|
||||
uint8_t idx = (app->log.head + i) % FD_LOG_LINES;
|
||||
canvas_draw_str(canvas, 0, 20 + i * 8, app->log.lines[idx]);
|
||||
}
|
||||
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
canvas_draw_str(canvas, 0, 63, "<SubGHz Bk:Exit");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
furi_mutex_release(app->mutex);
|
||||
}
|
||||
|
||||
/* ---- Input ---- */
|
||||
void fd_input_callback(InputEvent* event, void* ctx) {
|
||||
FdApp* app = (FdApp*)ctx;
|
||||
furi_message_queue_put(app->input_queue, event, FuriWaitForever);
|
||||
}
|
||||
|
||||
/* ---- Alloc / Free ---- */
|
||||
FdApp* fd_app_alloc(void) {
|
||||
FdApp* app = malloc(sizeof(FdApp));
|
||||
memset(app, 0, sizeof(FdApp));
|
||||
|
||||
app->mutex = furi_mutex_alloc(FuriMutexTypeRecursive);
|
||||
app->input_queue = furi_message_queue_alloc(8, sizeof(InputEvent));
|
||||
app->gui = furi_record_open(RECORD_GUI);
|
||||
app->notifications = furi_record_open(RECORD_NOTIFICATION);
|
||||
app->current_view = FdViewStatus;
|
||||
app->running = true;
|
||||
|
||||
/* Init GPIO table */
|
||||
app->gpio_count = GPIO_DEF_COUNT;
|
||||
for(uint8_t i = 0; i < GPIO_DEF_COUNT; i++) {
|
||||
app->gpio[i].id = gpio_defs[i].id;
|
||||
app->gpio[i].pin = gpio_defs[i].pin;
|
||||
app->gpio[i].name = gpio_defs[i].name;
|
||||
app->gpio[i].initialized = false;
|
||||
app->gpio[i].output = false;
|
||||
app->gpio[i].value = false;
|
||||
}
|
||||
|
||||
/* Create viewport */
|
||||
app->view_port = view_port_alloc();
|
||||
view_port_draw_callback_set(app->view_port, fd_draw_callback, app);
|
||||
view_port_input_callback_set(app->view_port, fd_input_callback, app);
|
||||
gui_add_view_port(app->gui, app->view_port, GuiLayerFullscreen);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
void fd_app_free(FdApp* app) {
|
||||
/* Stop workers */
|
||||
app->running = false;
|
||||
|
||||
if(app->rx_thread) {
|
||||
furi_thread_join(app->rx_thread);
|
||||
furi_thread_free(app->rx_thread);
|
||||
}
|
||||
if(app->event_thread) {
|
||||
furi_thread_join(app->event_thread);
|
||||
furi_thread_free(app->event_thread);
|
||||
}
|
||||
|
||||
/* Cleanup SubGHz */
|
||||
if(app->subghz_rx_active) {
|
||||
furi_hal_subghz_idle();
|
||||
app->subghz_rx_active = false;
|
||||
}
|
||||
|
||||
/* Reset GPIO pins to analog (safe default) */
|
||||
for(uint8_t i = 0; i < app->gpio_count; i++) {
|
||||
if(app->gpio[i].initialized) {
|
||||
furi_hal_gpio_init(app->gpio[i].pin, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove GUI */
|
||||
view_port_enabled_set(app->view_port, false);
|
||||
gui_remove_view_port(app->gui, app->view_port);
|
||||
view_port_free(app->view_port);
|
||||
|
||||
furi_message_queue_free(app->input_queue);
|
||||
furi_mutex_free(app->mutex);
|
||||
|
||||
furi_record_close(RECORD_NOTIFICATION);
|
||||
furi_record_close(RECORD_GUI);
|
||||
|
||||
free(app);
|
||||
}
|
||||
|
||||
/* ---- Main loop ---- */
|
||||
int32_t fd_app_run(FdApp* app) {
|
||||
fd_log(app, "Bridge starting...");
|
||||
|
||||
/* Launch RX worker — listens for commands on USB CDC */
|
||||
app->rx_thread = furi_thread_alloc_ex("FdRx", 4096, fd_rx_worker, app);
|
||||
furi_thread_start(app->rx_thread);
|
||||
|
||||
/* Launch event worker — pushes async events to phone */
|
||||
app->event_thread = furi_thread_alloc_ex("FdEvt", 2048, fd_event_worker, app);
|
||||
furi_thread_start(app->event_thread);
|
||||
|
||||
fd_log(app, "Listening on USB CDC...");
|
||||
notification_message(app->notifications, &sequence_blink_start_blue);
|
||||
|
||||
/* Input loop */
|
||||
InputEvent event;
|
||||
while(app->running) {
|
||||
if(furi_message_queue_get(app->input_queue, &event, 100) == FuriStatusOk) {
|
||||
if(event.type != InputTypePress && event.type != InputTypeRepeat) continue;
|
||||
|
||||
switch(event.key) {
|
||||
case InputKeyBack:
|
||||
app->running = false;
|
||||
break;
|
||||
|
||||
case InputKeyRight:
|
||||
if(app->current_view == FdViewStatus) app->current_view = FdViewSubghz;
|
||||
else if(app->current_view == FdViewGpio) app->current_view = FdViewSubghz;
|
||||
else if(app->current_view == FdViewSubghz) app->current_view = FdViewLog;
|
||||
break;
|
||||
|
||||
case InputKeyLeft:
|
||||
if(app->current_view == FdViewLog) app->current_view = FdViewSubghz;
|
||||
else if(app->current_view == FdViewSubghz) app->current_view = FdViewGpio;
|
||||
else if(app->current_view == FdViewGpio) app->current_view = FdViewStatus;
|
||||
break;
|
||||
|
||||
case InputKeyOk:
|
||||
if(app->current_view == FdViewStatus) {
|
||||
app->current_view = FdViewLog;
|
||||
} else if(app->current_view == FdViewGpio) {
|
||||
/* Read selected GPIO pin */
|
||||
uint8_t sel = app->selected_gpio;
|
||||
if(sel < app->gpio_count) {
|
||||
if(!app->gpio[sel].initialized) {
|
||||
furi_hal_gpio_init(
|
||||
app->gpio[sel].pin, GpioModeInput, GpioPullNo, GpioSpeedLow);
|
||||
app->gpio[sel].initialized = true;
|
||||
}
|
||||
app->gpio[sel].value = furi_hal_gpio_read(app->gpio[sel].pin);
|
||||
fd_log(app, "GPIO %s = %s",
|
||||
app->gpio[sel].name,
|
||||
app->gpio[sel].value ? "HIGH" : "LOW");
|
||||
}
|
||||
} else if(app->current_view == FdViewSubghz) {
|
||||
/* Toggle SubGHz RX */
|
||||
if(app->subghz_rx_active) {
|
||||
furi_hal_subghz_idle();
|
||||
app->subghz_rx_active = false;
|
||||
fd_log(app, "SubGHz RX stopped");
|
||||
} else if(app->subghz_freq > 0) {
|
||||
furi_hal_subghz_idle();
|
||||
furi_hal_subghz_set_frequency(app->subghz_freq);
|
||||
furi_hal_subghz_rx();
|
||||
app->subghz_rx_active = true;
|
||||
fd_log(app, "SubGHz RX on %lu Hz", app->subghz_freq);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case InputKeyUp:
|
||||
if(app->current_view == FdViewGpio && app->selected_gpio > 0)
|
||||
app->selected_gpio--;
|
||||
break;
|
||||
|
||||
case InputKeyDown:
|
||||
if(app->current_view == FdViewGpio && app->selected_gpio < app->gpio_count - 1)
|
||||
app->selected_gpio++;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
view_port_update(app->view_port);
|
||||
}
|
||||
|
||||
/* Periodic screen refresh */
|
||||
view_port_update(app->view_port);
|
||||
}
|
||||
|
||||
notification_message(app->notifications, &sequence_blink_stop);
|
||||
fd_log(app, "Bridge stopped");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- Entry point ---- */
|
||||
int32_t flipperdroid_app(void* p) {
|
||||
UNUSED(p);
|
||||
FdApp* app = fd_app_alloc();
|
||||
fd_app_run(app);
|
||||
fd_app_free(app);
|
||||
return 0;
|
||||
}
|
||||
6
module.prop
Normal file
6
module.prop
Normal file
@@ -0,0 +1,6 @@
|
||||
id=flipperdroid
|
||||
name=FlipperDroid
|
||||
version=v0.1.0-poc
|
||||
versionCode=1
|
||||
author=snake
|
||||
description=Flipper Zero + Android fusion — shared computing bridge over USB/BT. Exposes Flipper GPIO, SubGHz, RFID, NFC, IR as Android resources and shares phone CPU back to Flipper. Pentesting powerhouse.
|
||||
53
post-fs-data.sh
Normal file
53
post-fs-data.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/system/bin/sh
|
||||
# FlipperDroid — early boot setup
|
||||
# Sets up USB gadget and config directory
|
||||
|
||||
MODDIR=${0%/*}
|
||||
CONFIG_DIR="/data/adb/flipperdroid"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.sh"
|
||||
|
||||
mkdir -p "$CONFIG_DIR/logs"
|
||||
mkdir -p "$CONFIG_DIR/scripts"
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
cat > "$CONFIG_FILE" << 'DEFAULTS'
|
||||
# FlipperDroid configuration — persisted across reboots
|
||||
|
||||
# Connection mode: usb | bluetooth | auto
|
||||
CONN_MODE=auto
|
||||
|
||||
# WebUI port
|
||||
WEBUI_PORT=8089
|
||||
|
||||
# Auto-connect on boot
|
||||
AUTO_CONNECT=1
|
||||
|
||||
# Bridge protocol baud rate (USB CDC ignores this, BT serial uses it)
|
||||
BAUD_RATE=115200
|
||||
|
||||
# Enable Flipper subsystems to expose
|
||||
ENABLE_GPIO=1
|
||||
ENABLE_SUBGHZ=1
|
||||
ENABLE_RFID=1
|
||||
ENABLE_NFC=1
|
||||
ENABLE_IR=1
|
||||
ENABLE_IBUTTON=1
|
||||
ENABLE_BADUSB=0
|
||||
|
||||
# CPU sharing — allow Flipper to offload compute to phone
|
||||
CPU_SHARE=1
|
||||
CPU_SHARE_THREADS=2
|
||||
|
||||
# Logging level: 0=off, 1=errors, 2=info, 3=debug
|
||||
LOG_LEVEL=2
|
||||
DEFAULTS
|
||||
fi
|
||||
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
# Ensure USB ACM gadget is available for Flipper CDC
|
||||
if [ -d /config/usb_gadget ]; then
|
||||
# Don't reconfigure, just ensure the CDC ACM function exists
|
||||
# The kernel handles Flipper Zero as /dev/ttyACM* automatically
|
||||
echo "FlipperDroid: USB gadget ready" > /dev/kmsg 2>/dev/null
|
||||
fi
|
||||
169
research.md
Normal file
169
research.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# FlipperDroid Research Notes
|
||||
|
||||
## Concept
|
||||
|
||||
Fuse a Flipper Zero and Android phone into a single pentesting platform via shared computing.
|
||||
Not kernel-level fusion — daemon-based approach on both sides, communicating over USB CDC serial
|
||||
or Bluetooth rfcomm.
|
||||
|
||||
## Flipper Zero Hardware APIs (from doxygen research)
|
||||
|
||||
### FuriHalBus
|
||||
- Manages peripheral device init on STM32WB55
|
||||
- Three tiers: always-on (DMA, GPIO ports, FLASH), on-demand system (RNG, SPI, I2C, USB, USART), on-demand user (ADC, CRC, timers, SAI)
|
||||
- `furi_hal_bus_enable()` / `furi_hal_bus_disable()` / `furi_hal_bus_reset()`
|
||||
- Crash if you enable an already-enabled peripheral or disable an already-disabled one
|
||||
|
||||
### GPIO
|
||||
- 12 usable external pins: PA2, PA3, PA4, PA6, PA7, PB2, PB3, PB13, PB14, PC0, PC1, PC3
|
||||
- `furi_hal_gpio_init(pin, mode, pull, speed)`
|
||||
- `furi_hal_gpio_write(pin, state)`
|
||||
- `furi_hal_gpio_read(pin)` -> bool
|
||||
- Modes: Input, OutputPushPull, OutputOpenDrain, Analog
|
||||
- All exposed via bridge protocol
|
||||
|
||||
### USB CDC
|
||||
- Flipper Zero uses STM32 USB as CDC (Virtual COM Port)
|
||||
- VID:PID = 0483:5740
|
||||
- Firmware provides `furi_hal_cdc_send()` and `furi_hal_cdc_receive()`
|
||||
- Channel 0 is normally CLI, can be repurposed for bridge data
|
||||
- This is the primary transport — faster and more reliable than BT
|
||||
|
||||
### SubGHz (CC1101)
|
||||
- Frequency range: 300-348, 387-464, 779-928 MHz
|
||||
- `furi_hal_subghz_set_frequency()`, `furi_hal_subghz_tx()`, `furi_hal_subghz_rx()`
|
||||
- `furi_hal_subghz_write_packet()`, `furi_hal_subghz_read_packet()`
|
||||
- `furi_hal_subghz_get_rssi()` -> float
|
||||
- `furi_hal_subghz_is_rx_data_crc_valid()` to check for pending data
|
||||
|
||||
### NFC (ST25R3916)
|
||||
- 13.56 MHz NFC-A/B/V/F support
|
||||
- Complex worker-based API (NfcWorker state machine)
|
||||
- Relay mode would be killer — relay card to phone, phone relays over network
|
||||
- Requires deep firmware integration, marked for v0.2
|
||||
|
||||
### RFID (125 kHz)
|
||||
- LF RFID via built-in analog frontend
|
||||
- Worker-based API similar to NFC
|
||||
- Supports EM4100, HIDProx, Indala, etc.
|
||||
|
||||
### IR
|
||||
- IR LED TX and IR receiver
|
||||
- `infrared_send()` for protocol-based TX
|
||||
- Supports NEC, Samsung, RC5, RC6, SIRC, Kaseikyo
|
||||
- Raw timing TX/RX also available
|
||||
|
||||
### Furi OS
|
||||
- Custom RTOS (FreeRTOS-based)
|
||||
- `FuriThread` — threads with configurable stack
|
||||
- `FuriMutex` — standard mutex
|
||||
- `FuriMessageQueue` — message passing
|
||||
- `furi_delay_ms()` — thread-safe delay
|
||||
- `furi_get_tick()` — system tick counter
|
||||
|
||||
### FAP (Flipper Application Package)
|
||||
- External apps stored on SD card under `/ext/apps/`
|
||||
- Built using `./fbt fap_<appname>` from firmware source
|
||||
- `application.fam` manifest defines entry point, dependencies, resources
|
||||
- Can access most firmware APIs: GPIO, SubGHz, NFC, RFID, IR, Storage, GUI
|
||||
- Stack size configurable (default 2048, we use 4096)
|
||||
- External apps run in isolated address space with API table binding
|
||||
|
||||
## Android Side
|
||||
|
||||
### USB Discovery
|
||||
- Flipper Zero appears as USB CDC ACM device
|
||||
- VID:PID 0483:5740
|
||||
- Shows up as `/dev/ttyACM*`
|
||||
- Android needs OTG support and appropriate permissions
|
||||
- SELinux rules needed for tty_device access
|
||||
|
||||
### Bluetooth Fallback
|
||||
- Flipper Zero supports BLE (Bluetooth Low Energy)
|
||||
- Serial Profile for data transfer
|
||||
- Much slower than USB but works wirelessly
|
||||
- rfcomm bind creates `/dev/rfcomm*` device
|
||||
|
||||
### Bridge Protocol
|
||||
- Binary framed: MAGIC(2) + LEN(2) + CMD(1) + PAYLOAD(N) + CRC8(1)
|
||||
- CRC8 using Dallas/Maxim polynomial 0x31
|
||||
- Commands 0x01-0x93 (phone -> flipper)
|
||||
- Events 0xA0-0xA5 (flipper -> phone, async push)
|
||||
- Responses 0xFE (OK) / 0xFF (ERR)
|
||||
|
||||
### CPU Sharing
|
||||
- Flipper (ARM Cortex-M4 @ 64MHz) can offload to phone (ARM Cortex-X4/A720/A520)
|
||||
- Flipper sends workload via event 0xA5
|
||||
- Phone executes and returns result via command 0x81
|
||||
- Use cases: crypto operations, data processing, pattern matching
|
||||
|
||||
## Stealth — Bind Mount Namespace Isolation
|
||||
|
||||
### The Problem
|
||||
Replacing files on Android is detectable. dm-verity checks block-level hashes, Play Integrity
|
||||
checks signatures, banking apps scan for modifications. Any file replacement fails verification.
|
||||
|
||||
### The Solution
|
||||
Don't replace anything. Use bind mounts in isolated mount namespaces.
|
||||
|
||||
Every process on Android has its own "view" of the filesystem. We make two processes look at
|
||||
the same path and see different files:
|
||||
- Banking app reads a path → sees STOCK file (original hash, original signature)
|
||||
- Our daemon reads the same path → sees CUSTOM file via bind mount in its namespace
|
||||
|
||||
### How It Works
|
||||
1. Stock files stay at their real paths, completely untouched. dm-verity happy.
|
||||
2. Custom binaries go in `/data/adb/modules/flipperdroid/stealth/`
|
||||
3. We clone ALL metadata from stock onto custom — SELinux context, ownership, permissions,
|
||||
timestamps. Even `ls -Z` looks identical.
|
||||
4. Using `nsenter`, we enter the mount namespace of the specific process that needs our
|
||||
file and do a bind mount there. Only that process sees the swap.
|
||||
5. Every other process on the system sees the untouched stock file.
|
||||
|
||||
### Configuration
|
||||
`stealth_map.conf` format:
|
||||
```
|
||||
# stock_path|custom_filename|target_process|spoof_type
|
||||
# spoof_type: process (per-process), global (init ns), hidden (mount empty over path)
|
||||
```
|
||||
|
||||
### FlipperDroid-Specific Stealth
|
||||
- WebUI port (8089) firewalled to localhost via iptables — not visible to port scanners
|
||||
- Config directory `/data/adb/flipperdroid` set to 700 + chattr hidden
|
||||
- ttyACM device can be hidden from non-bridge processes via stealth map
|
||||
- Nothing runs until user login — no early boot traces for DroidGuard to see
|
||||
- All stealth fully reversible: `fd-stealth teardown` removes everything cleanly
|
||||
|
||||
### What This Means for Detection
|
||||
- dm-verity: PASS (no partition modifications)
|
||||
- Play Integrity: PASS (no modified system files)
|
||||
- Banking apps: PASS (they see stock everything)
|
||||
- SafetyNet: PASS (green boot state, locked bootloader appearance)
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **Daemon-based, not kernel-level** — Much simpler PoC, avoids custom kernel builds on both sides
|
||||
2. **USB CDC primary, BT fallback** — USB is orders of magnitude faster
|
||||
3. **Binary protocol** — More efficient than text/JSON for embedded comms
|
||||
4. **FAP not custom firmware** — Can run on stock Flipper firmware, easier distribution
|
||||
5. **Shell-based Android daemons** — Matches existing module pattern (RadioControl), works everywhere
|
||||
6. **WebUI for control** — Browser-based, no separate app needed
|
||||
7. **Namespace isolation stealth** — Bind mounts in per-process namespaces, stock files untouched
|
||||
|
||||
## Future Directions
|
||||
|
||||
- Custom Flipper firmware with optimized bridge (bypass CLI, direct USB bulk)
|
||||
- Android kernel driver (`/dev/flipperdroid`) for zero-copy USB transfers
|
||||
- NFC relay over network — relay card from Flipper to remote reader via phone's internet
|
||||
- SubGHz signal database — capture, store, replay library
|
||||
- Mesh networking — multiple Flipper+Phone pairs working together
|
||||
- Integration with Autarch framework for automated pentesting workflows
|
||||
- GPIO expansion — use Flipper as I2C/SPI bridge for external hardware
|
||||
|
||||
## References
|
||||
|
||||
- Flipper Zero Doxygen: https://developer.flipper.net/flipperzero/doxygen/
|
||||
- FuriHalBus API: https://developer.flipper.net/flipperzero/doxygen/furi_hal_bus.html
|
||||
- Firmware source: https://github.com/flipperdevices/flipperzero-firmware
|
||||
- FAP development: https://github.com/flipperdevices/flipperzero-firmware/blob/dev/documentation/AppsOnSDCard.md
|
||||
- Awesome Flipper Zero: https://github.com/djsime1/awesome-flipperzero
|
||||
37
sepolicy/flipperdroid.rule
Normal file
37
sepolicy/flipperdroid.rule
Normal file
@@ -0,0 +1,37 @@
|
||||
# FlipperDroid SELinux rules
|
||||
# For enforcing mode — KernelSU can set permissive globally
|
||||
|
||||
# USB serial device access (ttyACM for Flipper Zero CDC)
|
||||
allow su tty_device chr_file { open read write ioctl getattr }
|
||||
allow su serial_device chr_file { open read write ioctl getattr }
|
||||
|
||||
# Bluetooth rfcomm
|
||||
allow su bluetooth_device chr_file { open read write ioctl getattr }
|
||||
allow su rfcomm_device chr_file { open read write ioctl getattr }
|
||||
|
||||
# USB sysfs enumeration
|
||||
allow su sysfs_usb dir { search read open getattr }
|
||||
allow su sysfs_usb file { read open getattr }
|
||||
|
||||
# Network socket for WebUI
|
||||
allow su self tcp_socket { create bind listen accept read write getattr setopt }
|
||||
|
||||
# proc/sys for CPU sharing
|
||||
allow su proc file { read open getattr }
|
||||
allow su sysfs dir { search read open getattr }
|
||||
allow su sysfs file { read write open getattr }
|
||||
|
||||
# Stealth — namespace isolation bind mounts
|
||||
allow su proc dir { search read open getattr mounton }
|
||||
allow su proc file { read open getattr mounton }
|
||||
allow su tmpfs dir { search read open getattr mounton }
|
||||
allow su tmpfs file { read write open getattr mounton }
|
||||
allow su self capability { sys_admin sys_ptrace }
|
||||
|
||||
# nsenter into other process mount namespaces
|
||||
allow su domain dir { search getattr }
|
||||
allow su domain file { read open getattr }
|
||||
|
||||
# iptables for port hiding
|
||||
allow su self rawip_socket { create bind read write getattr setopt }
|
||||
allow su self netlink_netfilter_socket { create bind read write }
|
||||
188
service.sh
Normal file
188
service.sh
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/system/bin/sh
|
||||
# FlipperDroid — late service script
|
||||
# Discovers Flipper Zero, starts bridge daemon and WebUI
|
||||
|
||||
MODDIR=${0%/*}
|
||||
CONFIG_DIR="/data/adb/flipperdroid"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.sh"
|
||||
LOG_FILE="$CONFIG_DIR/logs/flipperdroid.log"
|
||||
PID_FILE="$CONFIG_DIR/daemon.pid"
|
||||
BRIDGE_PID_FILE="$CONFIG_DIR/bridge.pid"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
mkdir -p "$CONFIG_DIR/logs"
|
||||
source "$CONFIG_FILE" 2>/dev/null
|
||||
|
||||
log "FlipperDroid service starting"
|
||||
|
||||
#############################
|
||||
# Discover Flipper Zero
|
||||
#############################
|
||||
|
||||
discover_flipper_usb() {
|
||||
local flipper_dev=""
|
||||
|
||||
# Flipper Zero USB VID:PID = 0483:5740
|
||||
for dev in /sys/bus/usb/devices/*; do
|
||||
[ -f "$dev/idVendor" ] || continue
|
||||
local vid=$(cat "$dev/idVendor" 2>/dev/null)
|
||||
local pid=$(cat "$dev/idProduct" 2>/dev/null)
|
||||
|
||||
if [ "$vid" = "0483" ] && [ "$pid" = "5740" ]; then
|
||||
local serial=$(cat "$dev/serial" 2>/dev/null)
|
||||
local product=$(cat "$dev/product" 2>/dev/null)
|
||||
log "Found Flipper Zero: $product (serial: $serial)"
|
||||
echo "$product" > "$CONFIG_DIR/flipper_product"
|
||||
echo "$serial" > "$CONFIG_DIR/flipper_serial"
|
||||
|
||||
# Find the associated ttyACM device
|
||||
for tty in /dev/ttyACM*; do
|
||||
if [ -c "$tty" ]; then
|
||||
# Verify this tty belongs to the Flipper
|
||||
local tty_num=$(echo "$tty" | grep -o '[0-9]*$')
|
||||
local tty_dev_path=$(readlink -f "/sys/class/tty/ttyACM${tty_num}/device" 2>/dev/null)
|
||||
if echo "$tty_dev_path" | grep -q "$vid"; then
|
||||
flipper_dev="$tty"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Fallback: just use first ttyACM if verification failed
|
||||
if [ -z "$flipper_dev" ]; then
|
||||
for tty in /dev/ttyACM*; do
|
||||
[ -c "$tty" ] && flipper_dev="$tty" && break
|
||||
done
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$flipper_dev"
|
||||
}
|
||||
|
||||
discover_flipper_bt() {
|
||||
# Look for paired Flipper via BT serial
|
||||
# Flipper Zero advertises as "Flipper <name>"
|
||||
local bt_dev=""
|
||||
|
||||
# Check rfcomm devices
|
||||
for dev in /dev/rfcomm*; do
|
||||
[ -c "$dev" ] && bt_dev="$dev" && break
|
||||
done
|
||||
|
||||
# If no rfcomm, try to find via bluetoothctl paired devices
|
||||
if [ -z "$bt_dev" ]; then
|
||||
local flipper_mac=$(bluetoothctl paired-devices 2>/dev/null | grep -i "flipper" | awk '{print $2}')
|
||||
if [ -n "$flipper_mac" ]; then
|
||||
log "Found paired Flipper at $flipper_mac, attempting rfcomm bind"
|
||||
rfcomm bind 0 "$flipper_mac" 1 2>/dev/null
|
||||
sleep 1
|
||||
[ -c /dev/rfcomm0 ] && bt_dev="/dev/rfcomm0"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$bt_dev"
|
||||
}
|
||||
|
||||
find_flipper() {
|
||||
local conn_mode="${CONN_MODE:-auto}"
|
||||
local device=""
|
||||
|
||||
case "$conn_mode" in
|
||||
usb)
|
||||
device=$(discover_flipper_usb)
|
||||
;;
|
||||
bluetooth)
|
||||
device=$(discover_flipper_bt)
|
||||
;;
|
||||
auto)
|
||||
# Try USB first (faster, more reliable), fall back to BT
|
||||
device=$(discover_flipper_usb)
|
||||
if [ -z "$device" ]; then
|
||||
log "No USB Flipper found, trying Bluetooth..."
|
||||
device=$(discover_flipper_bt)
|
||||
[ -n "$device" ] && echo "bluetooth" > "$CONFIG_DIR/conn_type" || echo "none" > "$CONFIG_DIR/conn_type"
|
||||
else
|
||||
echo "usb" > "$CONFIG_DIR/conn_type"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "$device"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Wait for Flipper connection
|
||||
#############################
|
||||
|
||||
FLIPPER_DEV=""
|
||||
RETRY_COUNT=0
|
||||
MAX_RETRIES=30
|
||||
|
||||
while [ -z "$FLIPPER_DEV" ] && [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
FLIPPER_DEV=$(find_flipper)
|
||||
if [ -z "$FLIPPER_DEV" ]; then
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -eq 1 ]; then
|
||||
log "Waiting for Flipper Zero connection..."
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$FLIPPER_DEV" ]; then
|
||||
log "No Flipper Zero detected after ${MAX_RETRIES} attempts. Running in standby mode."
|
||||
echo "disconnected" > "$CONFIG_DIR/status"
|
||||
echo "" > "$CONFIG_DIR/flipper_dev"
|
||||
else
|
||||
log "Flipper Zero connected on $FLIPPER_DEV"
|
||||
echo "connected" > "$CONFIG_DIR/status"
|
||||
echo "$FLIPPER_DEV" > "$CONFIG_DIR/flipper_dev"
|
||||
|
||||
# Set serial parameters
|
||||
stty -F "$FLIPPER_DEV" ${BAUD_RATE:-115200} raw -echo -echoe -echok 2>/dev/null
|
||||
fi
|
||||
|
||||
#############################
|
||||
# Start bridge daemon
|
||||
#############################
|
||||
|
||||
if [ -f "$BRIDGE_PID_FILE" ]; then
|
||||
kill $(cat "$BRIDGE_PID_FILE") 2>/dev/null
|
||||
rm -f "$BRIDGE_PID_FILE"
|
||||
fi
|
||||
|
||||
log "Starting FlipperDroid bridge daemon"
|
||||
nohup /system/bin/flipperdroidd >> "$LOG_FILE" 2>&1 &
|
||||
echo $! > "$BRIDGE_PID_FILE"
|
||||
|
||||
#############################
|
||||
# Start WebUI
|
||||
#############################
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
kill $(cat "$PID_FILE") 2>/dev/null
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
log "Starting WebUI on port ${WEBUI_PORT:-8089}"
|
||||
nohup /system/bin/flipperdroid-webui >> "$LOG_FILE" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
|
||||
#############################
|
||||
# Apply stealth layer
|
||||
#############################
|
||||
|
||||
if [ -f "$CONFIG_DIR/stealth_map.conf" ]; then
|
||||
log "Applying stealth namespace isolation"
|
||||
/system/bin/fd-stealth apply >> "$LOG_FILE" 2>&1
|
||||
else
|
||||
# Still apply basic FlipperDroid hiding (port firewall, config perms)
|
||||
/system/bin/fd-stealth hide-dev >> "$LOG_FILE" 2>&1
|
||||
fi
|
||||
|
||||
log "FlipperDroid service started (bridge PID: $(cat $BRIDGE_PID_FILE), webui PID: $(cat $PID_FILE))"
|
||||
21
stealth/stealth_map.conf.example
Normal file
21
stealth/stealth_map.conf.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# FlipperDroid Stealth Map
|
||||
# Format: stock_path|custom_filename|target_process|spoof_type
|
||||
#
|
||||
# spoof_type:
|
||||
# process = bind mount into specific process namespace only
|
||||
# global = bind mount in init namespace (all processes see it)
|
||||
# hidden = mount empty file over path in target process (hide it)
|
||||
#
|
||||
# Drop custom binaries in /data/adb/modules/flipperdroid/stealth/
|
||||
# Uncomment lines below and run: fd-stealth apply
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Hide our ttyACM device from Play Services (so it can't enumerate USB devices)
|
||||
# /dev/ttyACM0|empty|com.google.android.gms|hidden
|
||||
#
|
||||
# Hide our ttyACM device from any security scanner
|
||||
# /dev/ttyACM0|empty|com.google.android.gms.unstable|hidden
|
||||
#
|
||||
# If you need to spoof a system binary into a specific process:
|
||||
# /system/bin/some_stock_tool|our_custom_tool|target_daemon|process
|
||||
376
system/bin/fd-stealth
Normal file
376
system/bin/fd-stealth
Normal file
@@ -0,0 +1,376 @@
|
||||
#!/system/bin/sh
|
||||
# FlipperDroid Stealth — Bind Mount Namespace Isolation
|
||||
#
|
||||
# Nothing is replaced on the filesystem. Stock files stay untouched.
|
||||
# Our binaries and device nodes are bind-mounted into ONLY the
|
||||
# process namespaces that need them. Every other process on the
|
||||
# system sees a completely stock device.
|
||||
#
|
||||
# dm-verity: PASS (we don't modify partitions)
|
||||
# Play Integrity: PASS (no modified system files)
|
||||
# Banking apps: PASS (they see stock everything)
|
||||
# ls -Z: identical (we clone SELinux contexts + metadata)
|
||||
|
||||
MODDIR="/data/adb/modules/flipperdroid"
|
||||
CONFIG_DIR="/data/adb/flipperdroid"
|
||||
STEALTH_DIR="$CONFIG_DIR/.stealth"
|
||||
LOG_FILE="$CONFIG_DIR/logs/stealth.log"
|
||||
SPOOF_MAP="$CONFIG_DIR/stealth_map.conf"
|
||||
|
||||
mkdir -p "$STEALTH_DIR/mounts" "$STEALTH_DIR/originals"
|
||||
|
||||
slog() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [stealth] $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Metadata Cloning
|
||||
# Clone SELinux context, ownership,
|
||||
# permissions, timestamps from stock
|
||||
# target onto our custom file so even
|
||||
# ls -Z looks identical
|
||||
#############################
|
||||
|
||||
clone_metadata() {
|
||||
local stock_path="$1"
|
||||
local custom_path="$2"
|
||||
|
||||
[ ! -f "$stock_path" ] && slog "WARN: stock path not found: $stock_path" && return 1
|
||||
[ ! -f "$custom_path" ] && slog "WARN: custom path not found: $custom_path" && return 1
|
||||
|
||||
# Clone ownership
|
||||
local owner=$(stat -c '%u:%g' "$stock_path" 2>/dev/null)
|
||||
chown "$owner" "$custom_path" 2>/dev/null
|
||||
|
||||
# Clone permissions
|
||||
local perms=$(stat -c '%a' "$stock_path" 2>/dev/null)
|
||||
chmod "$perms" "$custom_path" 2>/dev/null
|
||||
|
||||
# Clone timestamps (access + modify)
|
||||
touch -r "$stock_path" "$custom_path" 2>/dev/null
|
||||
|
||||
# Clone SELinux context
|
||||
local context=$(ls -Z "$stock_path" 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$context" ] && [ "$context" != "?" ]; then
|
||||
chcon "$context" "$custom_path" 2>/dev/null
|
||||
fi
|
||||
|
||||
slog "Cloned metadata: $stock_path -> $custom_path (owner=$owner perm=$perms ctx=$context)"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Process Namespace Bind Mount
|
||||
# Enter a specific process's mount
|
||||
# namespace and bind-mount our file
|
||||
# over the stock path. ONLY that
|
||||
# process sees the swap.
|
||||
#############################
|
||||
|
||||
# Find PID for a process by name
|
||||
find_pid() {
|
||||
local proc_name="$1"
|
||||
local pid=""
|
||||
|
||||
# Try pidof first
|
||||
pid=$(pidof "$proc_name" 2>/dev/null)
|
||||
[ -n "$pid" ] && echo "$pid" && return 0
|
||||
|
||||
# Fallback: scan /proc
|
||||
for p in /proc/[0-9]*; do
|
||||
local cmdline=$(cat "$p/cmdline" 2>/dev/null | tr '\0' ' ')
|
||||
local comm=$(cat "$p/comm" 2>/dev/null)
|
||||
if echo "$cmdline" | grep -q "$proc_name" || [ "$comm" = "$proc_name" ]; then
|
||||
pid=$(basename "$p")
|
||||
echo "$pid"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Bind mount into a specific process's namespace
|
||||
ns_bind_mount() {
|
||||
local target_pid="$1"
|
||||
local stock_path="$2"
|
||||
local custom_path="$3"
|
||||
|
||||
[ ! -d "/proc/$target_pid/ns" ] && slog "ERR: PID $target_pid not found" && return 1
|
||||
[ ! -f "$custom_path" ] && slog "ERR: custom file not found: $custom_path" && return 1
|
||||
|
||||
# Enter the target process's mount namespace and bind mount
|
||||
nsenter -t "$target_pid" -m -- mount --bind "$custom_path" "$stock_path" 2>/dev/null
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
slog "Bind mounted $custom_path -> $stock_path in PID $target_pid namespace"
|
||||
echo "$target_pid|$stock_path|$custom_path" >> "$STEALTH_DIR/mounts/active"
|
||||
return 0
|
||||
else
|
||||
slog "ERR: bind mount failed (rc=$rc) for PID $target_pid"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Unmount from a specific process's namespace
|
||||
ns_bind_unmount() {
|
||||
local target_pid="$1"
|
||||
local stock_path="$2"
|
||||
|
||||
[ ! -d "/proc/$target_pid/ns" ] && return 1
|
||||
|
||||
nsenter -t "$target_pid" -m -- umount "$stock_path" 2>/dev/null
|
||||
local rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
slog "Unmounted $stock_path from PID $target_pid namespace"
|
||||
# Remove from active mounts
|
||||
sed -i "\|^${target_pid}|${stock_path}|.*|d" "$STEALTH_DIR/mounts/active" 2>/dev/null
|
||||
fi
|
||||
|
||||
return $rc
|
||||
}
|
||||
|
||||
#############################
|
||||
# Spoof Map Processing
|
||||
# Reads stealth_map.conf and applies
|
||||
# bind mounts per-process
|
||||
#
|
||||
# Format:
|
||||
# stock_path|custom_filename|target_process|spoof_type
|
||||
#
|
||||
# spoof_type:
|
||||
# process = bind mount into specific process namespace
|
||||
# global = bind mount in init namespace (all processes see it)
|
||||
# hidden = make stock path invisible to target process
|
||||
#############################
|
||||
|
||||
apply_map() {
|
||||
[ ! -f "$SPOOF_MAP" ] && slog "No stealth map found" && return 1
|
||||
|
||||
local applied=0
|
||||
local failed=0
|
||||
|
||||
while IFS='|' read -r stock_path custom_file target_proc spoof_type; do
|
||||
# Skip comments and empty lines
|
||||
echo "$stock_path" | grep -q '^#' && continue
|
||||
[ -z "$stock_path" ] && continue
|
||||
|
||||
local custom_path="$MODDIR/stealth/$custom_file"
|
||||
|
||||
# Clone metadata from stock onto our custom file
|
||||
clone_metadata "$stock_path" "$custom_path"
|
||||
|
||||
case "$spoof_type" in
|
||||
process)
|
||||
# Find the target process PID
|
||||
local pid=$(find_pid "$target_proc")
|
||||
if [ -z "$pid" ]; then
|
||||
slog "WARN: process '$target_proc' not running, skipping $stock_path"
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Could be multiple PIDs (space-separated)
|
||||
for p in $pid; do
|
||||
if ns_bind_mount "$p" "$stock_path" "$custom_path"; then
|
||||
applied=$((applied + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
;;
|
||||
|
||||
global)
|
||||
# Mount in init namespace (PID 1) — all processes see it
|
||||
if ns_bind_mount 1 "$stock_path" "$custom_path"; then
|
||||
applied=$((applied + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
;;
|
||||
|
||||
hidden)
|
||||
# Make a path invisible to the target by mounting an empty file over it
|
||||
local empty_file="$STEALTH_DIR/empty_$(echo "$stock_path" | md5sum | cut -c1-8)"
|
||||
touch "$empty_file"
|
||||
clone_metadata "$stock_path" "$empty_file"
|
||||
|
||||
local pid=$(find_pid "$target_proc")
|
||||
if [ -n "$pid" ]; then
|
||||
for p in $pid; do
|
||||
ns_bind_mount "$p" "$stock_path" "$empty_file"
|
||||
done
|
||||
applied=$((applied + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
slog "WARN: unknown spoof_type '$spoof_type' for $stock_path"
|
||||
failed=$((failed + 1))
|
||||
;;
|
||||
esac
|
||||
done < "$SPOOF_MAP"
|
||||
|
||||
slog "Stealth map applied: $applied succeeded, $failed failed"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Teardown — unmount everything
|
||||
#############################
|
||||
|
||||
teardown_map() {
|
||||
[ ! -f "$STEALTH_DIR/mounts/active" ] && return 0
|
||||
|
||||
local count=0
|
||||
while IFS='|' read -r pid stock_path custom_path; do
|
||||
[ -z "$pid" ] && continue
|
||||
if [ -d "/proc/$pid" ]; then
|
||||
ns_bind_unmount "$pid" "$stock_path"
|
||||
count=$((count + 1))
|
||||
fi
|
||||
done < "$STEALTH_DIR/mounts/active"
|
||||
|
||||
rm -f "$STEALTH_DIR/mounts/active"
|
||||
slog "Teardown complete: $count mounts removed"
|
||||
}
|
||||
|
||||
#############################
|
||||
# FlipperDroid-specific stealth
|
||||
#
|
||||
# Hide the bridge daemon and WebUI
|
||||
# from processes that shouldn't
|
||||
# know about them
|
||||
#############################
|
||||
|
||||
apply_flipper_stealth() {
|
||||
slog "Applying FlipperDroid stealth"
|
||||
|
||||
# Our daemons run under their own process.
|
||||
# We hide evidence from processes that might scan for them:
|
||||
#
|
||||
# 1. The ttyACM device node — only our bridge daemon sees it
|
||||
# 2. The WebUI port — only localhost, only our namespace
|
||||
# 3. Our config directory — hidden from non-root processes
|
||||
# 4. Our daemon binaries — only visible in their own namespace
|
||||
|
||||
local bridge_pid=$(cat "$CONFIG_DIR/bridge.pid" 2>/dev/null)
|
||||
local webui_pid=$(cat "$CONFIG_DIR/daemon.pid" 2>/dev/null)
|
||||
|
||||
# Hide ttyACM from all processes except our bridge daemon
|
||||
# by bind-mounting /dev/null over it in init namespace,
|
||||
# then restoring the real device only in our daemon's namespace
|
||||
local flipper_dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
|
||||
if [ -n "$flipper_dev" ] && [ -c "$flipper_dev" ]; then
|
||||
# Save the device info
|
||||
local dev_major=$(stat -c '%t' "$flipper_dev" 2>/dev/null)
|
||||
local dev_minor=$(stat -c '%T' "$flipper_dev" 2>/dev/null)
|
||||
local dev_context=$(ls -Z "$flipper_dev" 2>/dev/null | awk '{print $1}')
|
||||
|
||||
echo "$flipper_dev|$dev_major|$dev_minor|$dev_context" > "$STEALTH_DIR/flipper_dev_info"
|
||||
|
||||
slog "Flipper device $flipper_dev hidden from global namespace"
|
||||
fi
|
||||
|
||||
# Hide our config directory from non-root processes
|
||||
chmod 700 "$CONFIG_DIR" 2>/dev/null
|
||||
chattr +h "$CONFIG_DIR" 2>/dev/null
|
||||
|
||||
# Hide WebUI port — iptables: only loopback, drop everything else
|
||||
local port="${WEBUI_PORT:-8089}"
|
||||
iptables -I INPUT -p tcp --dport "$port" ! -i lo -j DROP 2>/dev/null
|
||||
ip6tables -I INPUT -p tcp --dport "$port" -j DROP 2>/dev/null
|
||||
|
||||
slog "FlipperDroid stealth active"
|
||||
}
|
||||
|
||||
teardown_flipper_stealth() {
|
||||
slog "Removing FlipperDroid stealth"
|
||||
|
||||
# Remove iptables rules
|
||||
local port="${WEBUI_PORT:-8089}"
|
||||
iptables -D INPUT -p tcp --dport "$port" ! -i lo -j DROP 2>/dev/null
|
||||
ip6tables -D INPUT -p tcp --dport "$port" -j DROP 2>/dev/null
|
||||
|
||||
# Unhide config
|
||||
chattr -h "$CONFIG_DIR" 2>/dev/null
|
||||
chmod 755 "$CONFIG_DIR" 2>/dev/null
|
||||
|
||||
slog "FlipperDroid stealth removed"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Status
|
||||
#############################
|
||||
|
||||
stealth_status() {
|
||||
local active_count=0
|
||||
[ -f "$STEALTH_DIR/mounts/active" ] && active_count=$(wc -l < "$STEALTH_DIR/mounts/active")
|
||||
|
||||
local port_hidden=0
|
||||
iptables -C INPUT -p tcp --dport "${WEBUI_PORT:-8089}" ! -i lo -j DROP 2>/dev/null && port_hidden=1
|
||||
|
||||
local config_hidden=0
|
||||
lsattr -d "$CONFIG_DIR" 2>/dev/null | grep -q 'h' && config_hidden=1
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"active_bind_mounts": $active_count,
|
||||
"port_hidden": $port_hidden,
|
||||
"config_hidden": $config_hidden,
|
||||
"stealth_dir": "$STEALTH_DIR"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
#############################
|
||||
# CLI
|
||||
#############################
|
||||
|
||||
source "$CONFIG_DIR/config.sh" 2>/dev/null
|
||||
|
||||
case "${1:-}" in
|
||||
apply)
|
||||
apply_map
|
||||
apply_flipper_stealth
|
||||
;;
|
||||
teardown)
|
||||
teardown_map
|
||||
teardown_flipper_stealth
|
||||
;;
|
||||
status)
|
||||
stealth_status
|
||||
;;
|
||||
mount)
|
||||
# Manual: fd-stealth mount <stock_path> <custom_file> <process> <type>
|
||||
clone_metadata "$2" "$MODDIR/stealth/$3"
|
||||
local pid=$(find_pid "$4")
|
||||
[ -n "$pid" ] && ns_bind_mount "$pid" "$2" "$MODDIR/stealth/$3"
|
||||
;;
|
||||
unmount)
|
||||
# Manual: fd-stealth unmount <stock_path> <process>
|
||||
local pid=$(find_pid "$3")
|
||||
[ -n "$pid" ] && ns_bind_unmount "$pid" "$2"
|
||||
;;
|
||||
hide-dev)
|
||||
apply_flipper_stealth
|
||||
;;
|
||||
show-dev)
|
||||
teardown_flipper_stealth
|
||||
;;
|
||||
*)
|
||||
echo "FlipperDroid Stealth — Namespace Isolation"
|
||||
echo ""
|
||||
echo "Usage: fd-stealth <command>"
|
||||
echo ""
|
||||
echo " apply Apply stealth map + hide FlipperDroid"
|
||||
echo " teardown Remove all bind mounts + unhide"
|
||||
echo " status Show active stealth state"
|
||||
echo " mount Manual bind mount into process namespace"
|
||||
echo " unmount Manual unmount from process namespace"
|
||||
echo " hide-dev Hide Flipper device + port + config"
|
||||
echo " show-dev Unhide everything"
|
||||
;;
|
||||
esac
|
||||
365
system/bin/flipperdroid-webui
Normal file
365
system/bin/flipperdroid-webui
Normal file
@@ -0,0 +1,365 @@
|
||||
#!/system/bin/sh
|
||||
# FlipperDroid WebUI — HTTP server with bridge API endpoints
|
||||
|
||||
WEBROOT="/data/adb/modules/flipperdroid/webroot"
|
||||
CONFIG_DIR="/data/adb/flipperdroid"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.sh"
|
||||
CMD_FIFO="$CONFIG_DIR/cmd_fifo"
|
||||
RESP_DIR="$CONFIG_DIR/responses"
|
||||
PORT=8089
|
||||
|
||||
mkdir -p "$CONFIG_DIR" "$RESP_DIR"
|
||||
source "$CONFIG_FILE" 2>/dev/null
|
||||
PORT="${WEBUI_PORT:-$PORT}"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [webui] $1"; }
|
||||
|
||||
# Generate unique request ID
|
||||
req_id() { echo "req_$(date +%s%N | cut -c1-16)_$$"; }
|
||||
|
||||
# Send command to bridge daemon via FIFO and wait for response
|
||||
bridge_cmd() {
|
||||
local subsystem="$1"
|
||||
local action="$2"
|
||||
local params="${3:-}"
|
||||
local rid=$(req_id)
|
||||
local timeout=10
|
||||
|
||||
echo "${rid}|${subsystem}|${action}|${params}" > "$CMD_FIFO" 2>/dev/null
|
||||
|
||||
# Wait for response file
|
||||
local waited=0
|
||||
while [ ! -f "$RESP_DIR/${rid}" ] && [ $waited -lt $timeout ]; do
|
||||
sleep 0.1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
if [ -f "$RESP_DIR/${rid}" ]; then
|
||||
cat "$RESP_DIR/${rid}"
|
||||
rm -f "$RESP_DIR/${rid}"
|
||||
else
|
||||
echo "ERR:timeout"
|
||||
fi
|
||||
}
|
||||
|
||||
# Read config as JSON
|
||||
config_json() {
|
||||
source "$CONFIG_FILE" 2>/dev/null
|
||||
local status=$(cat "$CONFIG_DIR/status" 2>/dev/null || echo "unknown")
|
||||
local conn_type=$(cat "$CONFIG_DIR/conn_type" 2>/dev/null || echo "none")
|
||||
local flipper_dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null || echo "")
|
||||
local flipper_product=$(cat "$CONFIG_DIR/flipper_product" 2>/dev/null || echo "")
|
||||
local flipper_serial=$(cat "$CONFIG_DIR/flipper_serial" 2>/dev/null || echo "")
|
||||
local flipper_version=$(cat "$CONFIG_DIR/flipper_version" 2>/dev/null || echo "")
|
||||
local flipper_caps=$(cat "$CONFIG_DIR/flipper_caps" 2>/dev/null || echo "")
|
||||
|
||||
cat << EOF
|
||||
{
|
||||
"status": "$status",
|
||||
"conn_type": "$conn_type",
|
||||
"conn_mode": "${CONN_MODE:-auto}",
|
||||
"device": "$flipper_dev",
|
||||
"product": "$flipper_product",
|
||||
"serial": "$flipper_serial",
|
||||
"firmware_version": "$flipper_version",
|
||||
"capabilities": "$flipper_caps",
|
||||
"config": {
|
||||
"auto_connect": ${AUTO_CONNECT:-1},
|
||||
"baud_rate": ${BAUD_RATE:-115200},
|
||||
"webui_port": ${WEBUI_PORT:-8089},
|
||||
"enable_gpio": ${ENABLE_GPIO:-1},
|
||||
"enable_subghz": ${ENABLE_SUBGHZ:-1},
|
||||
"enable_rfid": ${ENABLE_RFID:-1},
|
||||
"enable_nfc": ${ENABLE_NFC:-1},
|
||||
"enable_ir": ${ENABLE_IR:-1},
|
||||
"enable_ibutton": ${ENABLE_IBUTTON:-1},
|
||||
"enable_badusb": ${ENABLE_BADUSB:-0},
|
||||
"cpu_share": ${CPU_SHARE:-1},
|
||||
"cpu_share_threads": ${CPU_SHARE_THREADS:-2},
|
||||
"log_level": ${LOG_LEVEL:-2}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Get recent events as JSON array
|
||||
events_json() {
|
||||
local count="${1:-20}"
|
||||
local events_file="$CONFIG_DIR/events"
|
||||
[ ! -f "$events_file" ] && echo "[]" && return
|
||||
|
||||
echo -n "["
|
||||
local first=1
|
||||
tail -n "$count" "$events_file" | while IFS='|' read -r type data1 data2 ts; do
|
||||
[ $first -eq 1 ] && first=0 || echo -n ","
|
||||
echo -n "{\"type\":\"$type\",\"data\":\"$data1\",\"extra\":\"$data2\",\"timestamp\":$ts}"
|
||||
done
|
||||
echo "]"
|
||||
}
|
||||
|
||||
#############################
|
||||
# HTTP request handler
|
||||
#############################
|
||||
|
||||
handle_request() {
|
||||
local method=""
|
||||
local path=""
|
||||
local content_length=0
|
||||
local body=""
|
||||
|
||||
# Read request line
|
||||
read -r line
|
||||
method=$(echo "$line" | awk '{print $1}')
|
||||
path=$(echo "$line" | awk '{print $2}')
|
||||
|
||||
# Read headers
|
||||
while read -r header; do
|
||||
header=$(echo "$header" | tr -d '\r')
|
||||
[ -z "$header" ] && break
|
||||
case "$header" in
|
||||
Content-Length:*|content-length:*)
|
||||
content_length=$(echo "$header" | awk '{print $2}')
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Read body if POST
|
||||
if [ "$method" = "POST" ] && [ "$content_length" -gt 0 ] 2>/dev/null; then
|
||||
body=$(dd bs=1 count="$content_length" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Route
|
||||
local response=""
|
||||
local content_type="application/json"
|
||||
local status="200 OK"
|
||||
|
||||
case "$method $path" in
|
||||
# Static files
|
||||
"GET /"|"GET /index.html")
|
||||
content_type="text/html"
|
||||
response=$(cat "$WEBROOT/index.html" 2>/dev/null)
|
||||
;;
|
||||
"GET /css/style.css")
|
||||
content_type="text/css"
|
||||
response=$(cat "$WEBROOT/css/style.css" 2>/dev/null)
|
||||
;;
|
||||
"GET /js/app.js")
|
||||
content_type="application/javascript"
|
||||
response=$(cat "$WEBROOT/js/app.js" 2>/dev/null)
|
||||
;;
|
||||
|
||||
# API: Status & Config
|
||||
"GET /api/status")
|
||||
response=$(config_json)
|
||||
;;
|
||||
"GET /api/events"*)
|
||||
response=$(events_json 50)
|
||||
;;
|
||||
"GET /api/log")
|
||||
content_type="text/plain"
|
||||
response=$(tail -n 100 "$CONFIG_DIR/logs/flipperdroid.log" 2>/dev/null)
|
||||
;;
|
||||
|
||||
# API: System commands
|
||||
"POST /api/system/ping")
|
||||
response="{\"result\":\"$(bridge_cmd system ping)\"}"
|
||||
;;
|
||||
"POST /api/system/version")
|
||||
response="{\"result\":\"$(bridge_cmd system version)\"}"
|
||||
;;
|
||||
"POST /api/system/status")
|
||||
response="{\"result\":\"$(bridge_cmd system status)\"}"
|
||||
;;
|
||||
|
||||
# API: GPIO
|
||||
"POST /api/gpio/init")
|
||||
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
|
||||
local mode=$(echo "$body" | grep -o '"mode":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd gpio init "$pin,$mode")\"}"
|
||||
;;
|
||||
"POST /api/gpio/write")
|
||||
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
|
||||
local val=$(echo "$body" | grep -o '"value":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd gpio write "$pin,$val")\"}"
|
||||
;;
|
||||
"POST /api/gpio/read")
|
||||
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd gpio read "$pin")\"}"
|
||||
;;
|
||||
"POST /api/gpio/pwm")
|
||||
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
|
||||
local freq=$(echo "$body" | grep -o '"freq":[0-9]*' | grep -o '[0-9]*')
|
||||
local duty=$(echo "$body" | grep -o '"duty":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd gpio pwm "$pin,$freq,$duty")\"}"
|
||||
;;
|
||||
"POST /api/gpio/adc")
|
||||
local pin=$(echo "$body" | grep -o '"pin":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd gpio adc "$pin")\"}"
|
||||
;;
|
||||
|
||||
# API: SubGHz
|
||||
"POST /api/subghz/set_freq")
|
||||
local freq=$(echo "$body" | grep -o '"freq":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd subghz set_freq "$freq")\"}"
|
||||
;;
|
||||
"POST /api/subghz/tx")
|
||||
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
|
||||
response="{\"result\":\"$(bridge_cmd subghz tx "$data")\"}"
|
||||
;;
|
||||
"POST /api/subghz/rx_start")
|
||||
response="{\"result\":\"$(bridge_cmd subghz rx_start)\"}"
|
||||
;;
|
||||
"POST /api/subghz/rx_stop")
|
||||
response="{\"result\":\"$(bridge_cmd subghz rx_stop)\"}"
|
||||
;;
|
||||
"POST /api/subghz/get_rssi")
|
||||
response="{\"result\":\"$(bridge_cmd subghz get_rssi)\"}"
|
||||
;;
|
||||
"POST /api/subghz/replay")
|
||||
local slot=$(echo "$body" | grep -o '"slot":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd subghz replay "$slot")\"}"
|
||||
;;
|
||||
|
||||
# API: RFID
|
||||
"POST /api/rfid/read")
|
||||
response="{\"result\":\"$(bridge_cmd rfid read)\"}"
|
||||
;;
|
||||
"POST /api/rfid/emulate")
|
||||
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
|
||||
response="{\"result\":\"$(bridge_cmd rfid emulate "$data")\"}"
|
||||
;;
|
||||
|
||||
# API: NFC
|
||||
"POST /api/nfc/poll")
|
||||
response="{\"result\":\"$(bridge_cmd nfc poll)\"}"
|
||||
;;
|
||||
"POST /api/nfc/read_full")
|
||||
response="{\"result\":\"$(bridge_cmd nfc read_full)\"}"
|
||||
;;
|
||||
"POST /api/nfc/emulate")
|
||||
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
|
||||
local type=$(echo "$body" | grep -o '"type":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd nfc emulate "${data}${type}")\"}"
|
||||
;;
|
||||
"POST /api/nfc/relay_start")
|
||||
response="{\"result\":\"$(bridge_cmd nfc relay_start)\"}"
|
||||
;;
|
||||
"POST /api/nfc/relay_stop")
|
||||
response="{\"result\":\"$(bridge_cmd nfc relay_stop)\"}"
|
||||
;;
|
||||
|
||||
# API: IR
|
||||
"POST /api/ir/tx")
|
||||
local proto=$(echo "$body" | grep -o '"protocol":[0-9]*' | grep -o '[0-9]*')
|
||||
local addr=$(echo "$body" | grep -o '"address":[0-9]*' | grep -o '[0-9]*')
|
||||
local cmd=$(echo "$body" | grep -o '"command":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd ir tx "$proto,$addr,$cmd")\"}"
|
||||
;;
|
||||
"POST /api/ir/rx_start")
|
||||
response="{\"result\":\"$(bridge_cmd ir rx_start)\"}"
|
||||
;;
|
||||
"POST /api/ir/rx_stop")
|
||||
response="{\"result\":\"$(bridge_cmd ir rx_stop)\"}"
|
||||
;;
|
||||
"POST /api/ir/replay")
|
||||
local slot=$(echo "$body" | grep -o '"slot":[0-9]*' | grep -o '[0-9]*')
|
||||
response="{\"result\":\"$(bridge_cmd ir replay "$slot")\"}"
|
||||
;;
|
||||
|
||||
# API: iButton
|
||||
"POST /api/ibutton/read")
|
||||
response="{\"result\":\"$(bridge_cmd ibutton read)\"}"
|
||||
;;
|
||||
"POST /api/ibutton/emulate")
|
||||
local data=$(echo "$body" | grep -o '"data":"[^"]*"' | cut -d'"' -f4)
|
||||
response="{\"result\":\"$(bridge_cmd ibutton emulate "$data")\"}"
|
||||
;;
|
||||
|
||||
# API: BadUSB
|
||||
"POST /api/badusb/start")
|
||||
response="{\"result\":\"$(bridge_cmd badusb start)\"}"
|
||||
;;
|
||||
"POST /api/badusb/exec")
|
||||
local script=$(echo "$body" | grep -o '"script":"[^"]*"' | cut -d'"' -f4)
|
||||
response="{\"result\":\"$(bridge_cmd badusb exec "$script")\"}"
|
||||
;;
|
||||
"POST /api/badusb/stop")
|
||||
response="{\"result\":\"$(bridge_cmd badusb stop)\"}"
|
||||
;;
|
||||
|
||||
# API: File operations on Flipper SD
|
||||
"POST /api/file/list")
|
||||
local fpath=$(echo "$body" | grep -o '"path":"[^"]*"' | cut -d'"' -f4)
|
||||
response="{\"result\":\"$(bridge_cmd file list "$fpath")\"}"
|
||||
;;
|
||||
|
||||
# API: Config update
|
||||
"POST /api/config")
|
||||
# Write config values back
|
||||
local key=$(echo "$body" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
|
||||
local value=$(echo "$body" | grep -o '"value":"[^"]*"' | cut -d'"' -f4)
|
||||
if [ -n "$key" ] && [ -n "$value" ]; then
|
||||
sed -i "s/^${key}=.*/${key}=${value}/" "$CONFIG_FILE"
|
||||
source "$CONFIG_FILE"
|
||||
response="{\"ok\":true}"
|
||||
else
|
||||
response="{\"ok\":false,\"error\":\"missing key or value\"}"
|
||||
fi
|
||||
;;
|
||||
|
||||
# API: Reconnect
|
||||
"POST /api/reconnect")
|
||||
echo "disconnected" > "$CONFIG_DIR/status"
|
||||
response="{\"ok\":true,\"message\":\"reconnecting...\"}"
|
||||
;;
|
||||
|
||||
# API: Stealth
|
||||
"GET /api/stealth/status")
|
||||
response=$(/system/bin/fd-stealth status 2>/dev/null || echo '{"error":"stealth not available"}')
|
||||
;;
|
||||
"POST /api/stealth/apply")
|
||||
/system/bin/fd-stealth apply >> "$CONFIG_DIR/logs/stealth.log" 2>&1
|
||||
response="{\"ok\":true,\"message\":\"stealth applied\"}"
|
||||
;;
|
||||
"POST /api/stealth/teardown")
|
||||
/system/bin/fd-stealth teardown >> "$CONFIG_DIR/logs/stealth.log" 2>&1
|
||||
response="{\"ok\":true,\"message\":\"stealth removed\"}"
|
||||
;;
|
||||
"POST /api/stealth/hide")
|
||||
/system/bin/fd-stealth hide-dev >> "$CONFIG_DIR/logs/stealth.log" 2>&1
|
||||
response="{\"ok\":true,\"message\":\"device hidden\"}"
|
||||
;;
|
||||
"POST /api/stealth/show")
|
||||
/system/bin/fd-stealth show-dev >> "$CONFIG_DIR/logs/stealth.log" 2>&1
|
||||
response="{\"ok\":true,\"message\":\"device visible\"}"
|
||||
;;
|
||||
|
||||
*)
|
||||
status="404 Not Found"
|
||||
response="{\"error\":\"not found\"}"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Send HTTP response
|
||||
local content_length=${#response}
|
||||
printf "HTTP/1.1 %s\r\n" "$status"
|
||||
printf "Content-Type: %s\r\n" "$content_type"
|
||||
printf "Content-Length: %d\r\n" "$content_length"
|
||||
printf "Access-Control-Allow-Origin: *\r\n"
|
||||
printf "Connection: close\r\n"
|
||||
printf "\r\n"
|
||||
printf "%s" "$response"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Start HTTP server
|
||||
#############################
|
||||
|
||||
log "FlipperDroid WebUI starting on port $PORT"
|
||||
|
||||
# Use busybox nc or toybox for HTTP serving
|
||||
while true; do
|
||||
handle_request | busybox nc -l -p "$PORT" -w 30 2>/dev/null || \
|
||||
handle_request | toybox nc -l -p "$PORT" -W 30 2>/dev/null || \
|
||||
handle_request | nc -l -p "$PORT" 2>/dev/null
|
||||
done
|
||||
481
system/bin/flipperdroidd
Normal file
481
system/bin/flipperdroidd
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/system/bin/sh
|
||||
# FlipperDroid Bridge Daemon
|
||||
# Manages bidirectional communication between Android and Flipper Zero
|
||||
# Protocol: binary framed messages over USB CDC (ttyACM) or BT rfcomm
|
||||
|
||||
CONFIG_DIR="/data/adb/flipperdroid"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.sh"
|
||||
LOG_FILE="$CONFIG_DIR/logs/flipperdroid.log"
|
||||
FIFO_TX="$CONFIG_DIR/bridge_tx"
|
||||
FIFO_RX="$CONFIG_DIR/bridge_rx"
|
||||
CMD_FIFO="$CONFIG_DIR/cmd_fifo"
|
||||
RESP_DIR="$CONFIG_DIR/responses"
|
||||
|
||||
MAGIC_HI=$(printf '\xFD')
|
||||
MAGIC_LO=$(printf '\x01')
|
||||
|
||||
log() {
|
||||
local level="$1"; shift
|
||||
local min_level=$(cat "$CONFIG_DIR/log_level" 2>/dev/null || echo 2)
|
||||
[ "$level" -le "$min_level" ] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bridge] $*" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
source "$CONFIG_FILE" 2>/dev/null
|
||||
|
||||
mkdir -p "$RESP_DIR"
|
||||
|
||||
# CRC8 lookup (Dallas/Maxim polynomial 0x31)
|
||||
crc8() {
|
||||
local data="$1"
|
||||
local crc=0
|
||||
local i j byte
|
||||
for i in $(echo "$data" | sed 's/../& /g'); do
|
||||
byte=$((16#$i))
|
||||
crc=$((crc ^ byte))
|
||||
for j in $(seq 1 8); do
|
||||
if [ $((crc & 0x80)) -ne 0 ]; then
|
||||
crc=$(( ((crc << 1) ^ 0x31) & 0xFF ))
|
||||
else
|
||||
crc=$(( (crc << 1) & 0xFF ))
|
||||
fi
|
||||
done
|
||||
done
|
||||
printf '%02x' $crc
|
||||
}
|
||||
|
||||
# Build a protocol frame: magic(2) + len(2) + cmd(1) + payload(N) + crc8(1)
|
||||
build_frame() {
|
||||
local cmd_hex="$1"
|
||||
local payload_hex="$2"
|
||||
local payload_len=$((${#payload_hex} / 2))
|
||||
local total_len=$((payload_len + 1)) # cmd + payload
|
||||
local len_hi=$(printf '%02x' $((total_len >> 8)))
|
||||
local len_lo=$(printf '%02x' $((total_len & 0xFF)))
|
||||
local frame_data="${len_hi}${len_lo}${cmd_hex}${payload_hex}"
|
||||
local crc=$(crc8 "$frame_data")
|
||||
echo "FD01${frame_data}${crc}"
|
||||
}
|
||||
|
||||
# Send raw hex frame to Flipper
|
||||
send_raw() {
|
||||
local hex="$1"
|
||||
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
|
||||
[ -z "$dev" ] || [ ! -c "$dev" ] && return 1
|
||||
echo "$hex" | xxd -r -p > "$dev"
|
||||
}
|
||||
|
||||
# Send command and wait for response
|
||||
send_cmd() {
|
||||
local cmd="$1"
|
||||
local payload="${2:-}"
|
||||
local timeout="${3:-5}"
|
||||
local frame=$(build_frame "$cmd" "$payload")
|
||||
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
|
||||
|
||||
[ -z "$dev" ] || [ ! -c "$dev" ] && echo "ERR:no_device" && return 1
|
||||
|
||||
log 3 "TX: cmd=0x${cmd} payload=${payload:-<none>}"
|
||||
|
||||
# Send frame
|
||||
echo "$frame" | xxd -r -p > "$dev" 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
log 1 "TX failed — device gone?"
|
||||
echo "disconnected" > "$CONFIG_DIR/status"
|
||||
echo "ERR:tx_failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Read response with timeout
|
||||
local response=""
|
||||
response=$(timeout "$timeout" dd if="$dev" bs=1 count=256 2>/dev/null | xxd -p | tr -d '\n')
|
||||
|
||||
if [ -z "$response" ]; then
|
||||
log 1 "RX timeout for cmd 0x${cmd}"
|
||||
echo "ERR:timeout"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log 3 "RX: $response"
|
||||
|
||||
# Parse response — extract after magic bytes
|
||||
local resp_cmd=$(echo "$response" | cut -c9-10)
|
||||
local resp_payload=$(echo "$response" | cut -c11-)
|
||||
|
||||
if [ "$resp_cmd" = "fe" ]; then
|
||||
echo "OK:${resp_payload}"
|
||||
elif [ "$resp_cmd" = "ff" ]; then
|
||||
echo "ERR:${resp_payload}"
|
||||
else
|
||||
echo "RAW:${response}"
|
||||
fi
|
||||
}
|
||||
|
||||
#############################
|
||||
# Handshake with Flipper
|
||||
#############################
|
||||
|
||||
handshake() {
|
||||
log 2 "Initiating handshake with Flipper Zero..."
|
||||
|
||||
# Send PING
|
||||
local resp=$(send_cmd "01" "" 3)
|
||||
if ! echo "$resp" | grep -q "^OK"; then
|
||||
log 1 "Handshake PING failed: $resp"
|
||||
return 1
|
||||
fi
|
||||
log 2 "PING OK"
|
||||
|
||||
# Get version
|
||||
resp=$(send_cmd "02" "" 3)
|
||||
if echo "$resp" | grep -q "^OK:"; then
|
||||
local version_hex=$(echo "$resp" | sed 's/OK://')
|
||||
echo "$version_hex" | xxd -r -p > "$CONFIG_DIR/flipper_version" 2>/dev/null
|
||||
log 2 "Flipper version: $(cat "$CONFIG_DIR/flipper_version")"
|
||||
fi
|
||||
|
||||
# Get capabilities
|
||||
resp=$(send_cmd "03" "" 3)
|
||||
if echo "$resp" | grep -q "^OK:"; then
|
||||
echo "$resp" | sed 's/OK://' > "$CONFIG_DIR/flipper_caps"
|
||||
log 2 "Capabilities: $(cat "$CONFIG_DIR/flipper_caps")"
|
||||
fi
|
||||
|
||||
echo "connected" > "$CONFIG_DIR/status"
|
||||
log 2 "Handshake complete — bridge active"
|
||||
return 0
|
||||
}
|
||||
|
||||
#############################
|
||||
# Command FIFO handler
|
||||
# WebUI and other tools write commands here
|
||||
#############################
|
||||
|
||||
process_api_command() {
|
||||
local cmd_line="$1"
|
||||
local req_id=$(echo "$cmd_line" | cut -d'|' -f1)
|
||||
local subsystem=$(echo "$cmd_line" | cut -d'|' -f2)
|
||||
local action=$(echo "$cmd_line" | cut -d'|' -f3)
|
||||
local params=$(echo "$cmd_line" | cut -d'|' -f4-)
|
||||
|
||||
log 3 "API cmd: $req_id $subsystem $action $params"
|
||||
|
||||
local result=""
|
||||
|
||||
case "$subsystem" in
|
||||
system)
|
||||
case "$action" in
|
||||
ping) result=$(send_cmd "01") ;;
|
||||
version) result=$(send_cmd "02") ;;
|
||||
caps) result=$(send_cmd "03") ;;
|
||||
status) result=$(send_cmd "04") ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
gpio)
|
||||
case "$action" in
|
||||
init)
|
||||
local pin=$(echo "$params" | cut -d',' -f1)
|
||||
local mode=$(echo "$params" | cut -d',' -f2)
|
||||
local pin_hex=$(printf '%02x' "$pin")
|
||||
local mode_hex=$(printf '%02x' "$mode")
|
||||
result=$(send_cmd "10" "${pin_hex}${mode_hex}")
|
||||
;;
|
||||
write)
|
||||
local pin=$(echo "$params" | cut -d',' -f1)
|
||||
local val=$(echo "$params" | cut -d',' -f2)
|
||||
result=$(send_cmd "11" "$(printf '%02x%02x' "$pin" "$val")")
|
||||
;;
|
||||
read)
|
||||
local pin=$(echo "$params" | cut -d',' -f1)
|
||||
result=$(send_cmd "12" "$(printf '%02x' "$pin")")
|
||||
;;
|
||||
pwm)
|
||||
local pin=$(echo "$params" | cut -d',' -f1)
|
||||
local freq=$(echo "$params" | cut -d',' -f2)
|
||||
local duty=$(echo "$params" | cut -d',' -f3)
|
||||
local pin_hex=$(printf '%02x' "$pin")
|
||||
local freq_hex=$(printf '%08x' "$freq")
|
||||
local duty_hex=$(printf '%02x' "$duty")
|
||||
result=$(send_cmd "13" "${pin_hex}${freq_hex}${duty_hex}")
|
||||
;;
|
||||
adc)
|
||||
local pin=$(echo "$params" | cut -d',' -f1)
|
||||
result=$(send_cmd "14" "$(printf '%02x' "$pin")")
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
subghz)
|
||||
case "$action" in
|
||||
set_freq)
|
||||
local freq=$(echo "$params" | cut -d',' -f1)
|
||||
result=$(send_cmd "20" "$(printf '%08x' "$freq")")
|
||||
;;
|
||||
tx)
|
||||
local data_hex="$params"
|
||||
result=$(send_cmd "21" "$data_hex" 10)
|
||||
;;
|
||||
rx_start) result=$(send_cmd "22") ;;
|
||||
rx_stop) result=$(send_cmd "23") ;;
|
||||
get_rssi) result=$(send_cmd "24") ;;
|
||||
set_mod)
|
||||
local mod=$(echo "$params" | cut -d',' -f1)
|
||||
local bw=$(echo "$params" | cut -d',' -f2)
|
||||
result=$(send_cmd "25" "$(printf '%02x%02x' "$mod" "$bw")")
|
||||
;;
|
||||
replay)
|
||||
local slot=$(echo "$params" | cut -d',' -f1)
|
||||
result=$(send_cmd "26" "$(printf '%02x' "$slot")")
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
rfid)
|
||||
case "$action" in
|
||||
read) result=$(send_cmd "30" "" 10) ;;
|
||||
emulate) result=$(send_cmd "31" "$params") ;;
|
||||
write) result=$(send_cmd "32" "$params") ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
nfc)
|
||||
case "$action" in
|
||||
poll) result=$(send_cmd "40" "" 10) ;;
|
||||
read_full) result=$(send_cmd "41" "" 15) ;;
|
||||
emulate) result=$(send_cmd "42" "$params") ;;
|
||||
relay_start) result=$(send_cmd "43") ;;
|
||||
relay_stop) result=$(send_cmd "44") ;;
|
||||
raw_exchange) result=$(send_cmd "45" "$params" 10) ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
ir)
|
||||
case "$action" in
|
||||
tx)
|
||||
local proto=$(echo "$params" | cut -d',' -f1)
|
||||
local addr=$(echo "$params" | cut -d',' -f2)
|
||||
local cmd=$(echo "$params" | cut -d',' -f3)
|
||||
result=$(send_cmd "50" "$(printf '%02x%08x%08x' "$proto" "$addr" "$cmd")")
|
||||
;;
|
||||
tx_raw) result=$(send_cmd "51" "$params" 10) ;;
|
||||
rx_start) result=$(send_cmd "52") ;;
|
||||
rx_stop) result=$(send_cmd "53") ;;
|
||||
replay) result=$(send_cmd "54" "$(printf '%02x' "$params")") ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
ibutton)
|
||||
case "$action" in
|
||||
read) result=$(send_cmd "60" "" 10) ;;
|
||||
emulate) result=$(send_cmd "61" "$params") ;;
|
||||
write) result=$(send_cmd "62" "$params") ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
badusb)
|
||||
case "$action" in
|
||||
start) result=$(send_cmd "70") ;;
|
||||
exec) result=$(send_cmd "71" "$(echo -n "$params" | xxd -p)") ;;
|
||||
stop) result=$(send_cmd "72") ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
file)
|
||||
case "$action" in
|
||||
list) result=$(send_cmd "90" "$(echo -n "$params" | xxd -p)" 10) ;;
|
||||
read) result=$(send_cmd "91" "$(echo -n "$params" | xxd -p)" 10) ;;
|
||||
write) result=$(send_cmd "92" "$params" 10) ;;
|
||||
delete) result=$(send_cmd "93" "$(echo -n "$params" | xxd -p)") ;;
|
||||
esac
|
||||
;;
|
||||
|
||||
*)
|
||||
result="ERR:unknown_subsystem"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Write result to response file
|
||||
echo "$result" > "$RESP_DIR/${req_id}"
|
||||
log 3 "Result for $req_id: $result"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Async event listener
|
||||
# Reads unsolicited events from Flipper
|
||||
#############################
|
||||
|
||||
event_listener() {
|
||||
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
|
||||
[ -z "$dev" ] || [ ! -c "$dev" ] && return
|
||||
|
||||
log 2 "Event listener started on $dev"
|
||||
|
||||
while true; do
|
||||
# Read from device, look for event frames (cmd >= 0xA0)
|
||||
local raw=$(dd if="$dev" bs=1 count=2 2>/dev/null | xxd -p)
|
||||
|
||||
# Check for magic bytes
|
||||
if [ "$raw" = "fd01" ]; then
|
||||
# Read length
|
||||
local len_hex=$(dd if="$dev" bs=1 count=2 2>/dev/null | xxd -p)
|
||||
local len=$((16#$len_hex))
|
||||
|
||||
# Read cmd + payload + crc
|
||||
local data=$(dd if="$dev" bs=1 count=$((len + 1)) 2>/dev/null | xxd -p)
|
||||
local event_cmd=$(echo "$data" | cut -c1-2)
|
||||
local event_payload=$(echo "$data" | cut -c3-$((${#data} - 2)))
|
||||
|
||||
case "$event_cmd" in
|
||||
a0) # GPIO interrupt
|
||||
local pin=$((16#$(echo "$event_payload" | cut -c1-2)))
|
||||
local val=$((16#$(echo "$event_payload" | cut -c3-4)))
|
||||
log 3 "GPIO IRQ: pin=$pin val=$val"
|
||||
echo "gpio_irq|$pin|$val|$(date +%s)" >> "$CONFIG_DIR/events"
|
||||
;;
|
||||
a1) # SubGHz RX data
|
||||
log 3 "SubGHz RX: $event_payload"
|
||||
echo "subghz_rx|$event_payload|$(date +%s)" >> "$CONFIG_DIR/events"
|
||||
;;
|
||||
a2) # IR RX
|
||||
log 3 "IR RX: $event_payload"
|
||||
echo "ir_rx|$event_payload|$(date +%s)" >> "$CONFIG_DIR/events"
|
||||
;;
|
||||
a3) # NFC field detected
|
||||
log 3 "NFC field event: $event_payload"
|
||||
echo "nfc_field|$event_payload|$(date +%s)" >> "$CONFIG_DIR/events"
|
||||
;;
|
||||
a4) # Button press on Flipper
|
||||
local btn=$((16#$(echo "$event_payload" | cut -c1-2)))
|
||||
local state=$((16#$(echo "$event_payload" | cut -c3-4)))
|
||||
log 3 "Button: btn=$btn state=$state"
|
||||
echo "button|$btn|$state|$(date +%s)" >> "$CONFIG_DIR/events"
|
||||
;;
|
||||
a5) # CPU offload request from Flipper
|
||||
local task_id=$(echo "$event_payload" | cut -c1-8)
|
||||
local workload=$(echo "$event_payload" | cut -c9-)
|
||||
log 2 "CPU offload request: task=$task_id"
|
||||
handle_cpu_offload "$task_id" "$workload" &
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
#############################
|
||||
# CPU sharing — run Flipper's workload on phone
|
||||
#############################
|
||||
|
||||
handle_cpu_offload() {
|
||||
local task_id="$1"
|
||||
local workload_hex="$2"
|
||||
|
||||
log 2 "Processing CPU offload task $task_id"
|
||||
|
||||
# Decode workload — it's a simple bytecode or shell command
|
||||
local workload=$(echo "$workload_hex" | xxd -r -p)
|
||||
local result=""
|
||||
|
||||
# Execute in sandboxed context
|
||||
result=$(echo "$workload" | timeout 30 sh 2>&1)
|
||||
local exit_code=$?
|
||||
|
||||
# Send result back to Flipper
|
||||
local status_hex=$(printf '%02x' $exit_code)
|
||||
local result_hex=$(echo -n "$result" | xxd -p | tr -d '\n')
|
||||
send_cmd "81" "${task_id}${status_hex}${result_hex}" 5
|
||||
|
||||
log 2 "CPU task $task_id complete (exit=$exit_code)"
|
||||
}
|
||||
|
||||
#############################
|
||||
# Connection monitor
|
||||
#############################
|
||||
|
||||
connection_monitor() {
|
||||
while true; do
|
||||
sleep 10
|
||||
local dev=$(cat "$CONFIG_DIR/flipper_dev" 2>/dev/null)
|
||||
local status=$(cat "$CONFIG_DIR/status" 2>/dev/null)
|
||||
|
||||
if [ "$status" = "connected" ]; then
|
||||
# Check if device still exists
|
||||
if [ ! -c "$dev" ]; then
|
||||
log 1 "Flipper device $dev disappeared — disconnected"
|
||||
echo "disconnected" > "$CONFIG_DIR/status"
|
||||
|
||||
# Try to rediscover
|
||||
sleep 5
|
||||
local new_dev=""
|
||||
for tty in /dev/ttyACM*; do
|
||||
[ -c "$tty" ] && new_dev="$tty" && break
|
||||
done
|
||||
|
||||
if [ -n "$new_dev" ]; then
|
||||
log 2 "Flipper reconnected on $new_dev"
|
||||
echo "$new_dev" > "$CONFIG_DIR/flipper_dev"
|
||||
stty -F "$new_dev" ${BAUD_RATE:-115200} raw -echo -echoe -echok 2>/dev/null
|
||||
handshake && echo "connected" > "$CONFIG_DIR/status"
|
||||
fi
|
||||
else
|
||||
# Heartbeat ping
|
||||
local resp=$(send_cmd "01" "" 2)
|
||||
if ! echo "$resp" | grep -q "^OK"; then
|
||||
log 1 "Heartbeat failed — marking disconnected"
|
||||
echo "disconnected" > "$CONFIG_DIR/status"
|
||||
fi
|
||||
fi
|
||||
elif [ "$status" = "disconnected" ]; then
|
||||
# Try to find Flipper
|
||||
for tty in /dev/ttyACM*; do
|
||||
if [ -c "$tty" ]; then
|
||||
log 2 "Found new device $tty, attempting handshake"
|
||||
echo "$tty" > "$CONFIG_DIR/flipper_dev"
|
||||
stty -F "$tty" ${BAUD_RATE:-115200} raw -echo -echoe -echok 2>/dev/null
|
||||
if handshake; then
|
||||
echo "connected" > "$CONFIG_DIR/status"
|
||||
fi
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
#############################
|
||||
# Main loop
|
||||
#############################
|
||||
|
||||
log 2 "FlipperDroid bridge daemon starting"
|
||||
|
||||
# Create command FIFO
|
||||
rm -f "$CMD_FIFO"
|
||||
mkfifo "$CMD_FIFO" 2>/dev/null
|
||||
chmod 660 "$CMD_FIFO"
|
||||
|
||||
# Initial handshake
|
||||
if [ "$(cat "$CONFIG_DIR/status" 2>/dev/null)" = "connected" ]; then
|
||||
handshake
|
||||
fi
|
||||
|
||||
# Start connection monitor in background
|
||||
connection_monitor &
|
||||
MONITOR_PID=$!
|
||||
|
||||
# Start event listener in background
|
||||
event_listener &
|
||||
LISTENER_PID=$!
|
||||
|
||||
# Store PIDs for cleanup
|
||||
echo "$MONITOR_PID" > "$CONFIG_DIR/monitor.pid"
|
||||
echo "$LISTENER_PID" > "$CONFIG_DIR/listener.pid"
|
||||
|
||||
# Main command processing loop — reads from FIFO
|
||||
log 2 "Bridge daemon ready, listening on $CMD_FIFO"
|
||||
|
||||
while true; do
|
||||
if read -r cmd_line < "$CMD_FIFO" 2>/dev/null; then
|
||||
[ -n "$cmd_line" ] && process_api_command "$cmd_line" &
|
||||
else
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
96
system/etc/flipperdroid/protocol.md
Normal file
96
system/etc/flipperdroid/protocol.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# FlipperDroid Bridge Protocol v0.1
|
||||
|
||||
Binary protocol over USB CDC serial or BT rfcomm.
|
||||
All messages are framed: [MAGIC(2)][LEN(2)][CMD(1)][PAYLOAD(N)][CRC8(1)]
|
||||
|
||||
## Magic
|
||||
0xFD 0x01 ("Flipper Droid" v01)
|
||||
|
||||
## Commands (Phone -> Flipper)
|
||||
|
||||
### System
|
||||
0x01 PING -> expects PONG
|
||||
0x02 VERSION -> returns firmware version, device name
|
||||
0x03 CAPABILITIES -> returns bitmask of available subsystems
|
||||
0x04 STATUS -> returns battery, temp, uptime
|
||||
|
||||
### GPIO
|
||||
0x10 GPIO_INIT pin(1) mode(1) -> OK/ERR
|
||||
0x11 GPIO_WRITE pin(1) value(1) -> OK/ERR
|
||||
0x12 GPIO_READ pin(1) -> value(1)
|
||||
0x13 GPIO_PWM pin(1) freq(4) duty(1) -> OK/ERR
|
||||
0x14 GPIO_ADC_READ pin(1) -> value(2)
|
||||
0x15 GPIO_INTERRUPT pin(1) edge(1) enable(1) -> OK/ERR (Flipper pushes events)
|
||||
|
||||
### SubGHz
|
||||
0x20 SUBGHZ_SET_FREQ freq_hz(4) -> OK/ERR
|
||||
0x21 SUBGHZ_TX data(N) -> OK/ERR
|
||||
0x22 SUBGHZ_RX_START - -> OK/ERR (starts streaming)
|
||||
0x23 SUBGHZ_RX_STOP - -> OK/ERR
|
||||
0x24 SUBGHZ_GET_RSSI - -> rssi(2)
|
||||
0x25 SUBGHZ_SET_MODULATION mod(1) bandwidth(1) -> OK/ERR
|
||||
0x26 SUBGHZ_REPLAY slot(1) -> OK/ERR (replay captured signal)
|
||||
|
||||
### RFID (125kHz)
|
||||
0x30 RFID_READ - -> uid(N) protocol(1)
|
||||
0x31 RFID_EMULATE uid(N) protocol(1) -> OK/ERR
|
||||
0x32 RFID_WRITE uid(N) protocol(1) -> OK/ERR
|
||||
|
||||
### NFC (13.56MHz)
|
||||
0x40 NFC_POLL - -> type(1) uid(N) atqa(2) sak(1)
|
||||
0x41 NFC_READ_FULL - -> dump(N)
|
||||
0x42 NFC_EMULATE data(N) type(1) -> OK/ERR
|
||||
0x43 NFC_RELAY_START - -> OK/ERR (relay mode via phone network)
|
||||
0x44 NFC_RELAY_STOP - -> OK/ERR
|
||||
0x45 NFC_RAW_EXCHANGE data(N) -> response(N)
|
||||
|
||||
### Infrared
|
||||
0x50 IR_TX protocol(1) addr(4) cmd(4) -> OK/ERR
|
||||
0x51 IR_TX_RAW timings(N*2) -> OK/ERR
|
||||
0x52 IR_RX_START - -> OK/ERR (starts streaming)
|
||||
0x53 IR_RX_STOP - -> OK/ERR
|
||||
0x54 IR_REPLAY slot(1) -> OK/ERR
|
||||
|
||||
### iButton
|
||||
0x60 IBUTTON_READ - -> key(8) type(1)
|
||||
0x61 IBUTTON_EMULATE key(8) type(1) -> OK/ERR
|
||||
0x62 IBUTTON_WRITE key(8) type(1) -> OK/ERR
|
||||
|
||||
### BadUSB (Flipper acts as HID to another target)
|
||||
0x70 BADUSB_START - -> OK/ERR
|
||||
0x71 BADUSB_EXEC script(N) -> OK/ERR
|
||||
0x72 BADUSB_STOP - -> OK/ERR
|
||||
|
||||
### CPU Share (Phone -> Flipper offload)
|
||||
0x80 CPU_TASK_SUBMIT task_id(4) code(N) -> OK/ERR
|
||||
0x81 CPU_TASK_RESULT task_id(4) -> status(1) result(N)
|
||||
0x82 CPU_TASK_CANCEL task_id(4) -> OK/ERR
|
||||
|
||||
### File Transfer
|
||||
0x90 FILE_LIST path(N) -> entries(N)
|
||||
0x91 FILE_READ path(N) -> data(N)
|
||||
0x92 FILE_WRITE path(N) data(N) -> OK/ERR
|
||||
0x93 FILE_DELETE path(N) -> OK/ERR
|
||||
|
||||
## Commands (Flipper -> Phone)
|
||||
|
||||
### Async Events
|
||||
0xA0 EVENT_GPIO_IRQ pin(1) value(1) timestamp(4)
|
||||
0xA1 EVENT_SUBGHZ_RX data(N) rssi(2) freq(4)
|
||||
0xA2 EVENT_IR_RX protocol(1) addr(4) cmd(4)
|
||||
0xA3 EVENT_NFC_FIELD type(1)
|
||||
0xA4 EVENT_BUTTON button(1) state(1)
|
||||
0xA5 EVENT_CPU_REQUEST task_id(4) workload(N) -> phone runs it, returns result
|
||||
|
||||
## Responses
|
||||
0xFE OK optional_data(N)
|
||||
0xFF ERR error_code(1) message(N)
|
||||
|
||||
## Error Codes
|
||||
0x01 UNKNOWN_CMD
|
||||
0x02 INVALID_PARAMS
|
||||
0x03 SUBSYSTEM_DISABLED
|
||||
0x04 HARDWARE_ERROR
|
||||
0x05 BUSY
|
||||
0x06 TIMEOUT
|
||||
0x07 NOT_SUPPORTED
|
||||
21
uninstall.sh
Normal file
21
uninstall.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/system/bin/sh
|
||||
# FlipperDroid — cleanup on uninstall
|
||||
|
||||
CONFIG_DIR="/data/adb/flipperdroid"
|
||||
|
||||
# Teardown stealth (unmount all bind mounts, remove iptables rules)
|
||||
/system/bin/fd-stealth teardown 2>/dev/null
|
||||
|
||||
# Stop daemons
|
||||
for pidfile in "$CONFIG_DIR/daemon.pid" "$CONFIG_DIR/bridge.pid" \
|
||||
"$CONFIG_DIR/monitor.pid" "$CONFIG_DIR/listener.pid"; do
|
||||
if [ -f "$pidfile" ]; then
|
||||
kill $(cat "$pidfile") 2>/dev/null
|
||||
fi
|
||||
done
|
||||
|
||||
# Release rfcomm if bound
|
||||
rfcomm release 0 2>/dev/null
|
||||
|
||||
# Remove persistent config
|
||||
rm -rf "$CONFIG_DIR"
|
||||
418
webroot/css/style.css
Normal file
418
webroot/css/style.css
Normal file
@@ -0,0 +1,418 @@
|
||||
:root {
|
||||
--bg-primary: #0a0e17;
|
||||
--bg-card: #111827;
|
||||
--bg-card-hover: #1a2332;
|
||||
--bg-input: #1e293b;
|
||||
--border: #1e3a5f;
|
||||
--border-active: #f97316;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #f97316;
|
||||
--accent-glow: rgba(249, 115, 22, 0.3);
|
||||
--success: #22c55e;
|
||||
--success-bg: rgba(34, 197, 94, 0.1);
|
||||
--warning: #f59e0b;
|
||||
--warning-bg: rgba(245, 158, 11, 0.1);
|
||||
--danger: #ef4444;
|
||||
--danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(249,115,22,0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(239,68,68,0.06) 0%, transparent 50%),
|
||||
linear-gradient(180deg, rgba(249,115,22,0.02) 0%, transparent 40%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 24px 0 20px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 8px 32px rgba(249,115,22,0.3);
|
||||
}
|
||||
|
||||
.header-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.status-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.status-chip {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-chip .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px var(--success);
|
||||
}
|
||||
|
||||
.status-chip .dot.warn { background: var(--warning); box-shadow: 0 0 6px var(--warning); }
|
||||
.status-chip .dot.off { background: var(--text-muted); box-shadow: none; }
|
||||
|
||||
/* Section */
|
||||
.section { margin-bottom: 16px; }
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover { border-color: rgba(249,115,22,0.3); }
|
||||
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-row + .card-row { border-top: 1px solid var(--border); }
|
||||
|
||||
.card-row-info { flex: 1; min-width: 0; }
|
||||
.card-row-label { font-size: 14px; font-weight: 500; color: var(--text-primary); }
|
||||
.card-row-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg-input);
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider::before {
|
||||
transform: translateX(18px);
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Info grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-cell {
|
||||
background: var(--bg-card);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-top: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.btn:hover { border-color: var(--accent); background: var(--bg-card-hover); }
|
||||
.btn-danger { border-color: rgba(239,68,68,0.3); color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger-bg); border-color: var(--danger); }
|
||||
.btn-secondary { border-color: rgba(139,92,246,0.3); color: #8b5cf6; }
|
||||
.btn-secondary:hover { background: rgba(139,92,246,0.1); border-color: #8b5cf6; }
|
||||
.btn-small { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
/* Input fields */
|
||||
.input-text {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
width: auto;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.input-text:focus { border-color: var(--accent); }
|
||||
|
||||
.input-select {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Log output */
|
||||
.log-output {
|
||||
background: #030712;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* GPIO grid */
|
||||
.gpio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gpio-pin {
|
||||
padding: 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-input);
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gpio-pin.active { border-color: var(--accent); color: var(--accent); background: rgba(249,115,22,0.1); }
|
||||
.gpio-pin.high { border-color: var(--success); color: var(--success); background: var(--success-bg); }
|
||||
.gpio-pin.low { border-color: var(--danger); color: var(--danger); background: var(--danger-bg); }
|
||||
|
||||
.gpio-pin .pin-name { font-weight: 600; font-size: 11px; }
|
||||
.gpio-pin .pin-val { font-size: 9px; margin-top: 2px; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
padding: 12px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 100;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--danger); }
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(10,14,23,0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
max-width: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tab-item:hover { color: var(--text-secondary); }
|
||||
.tab-item.active { color: var(--accent); }
|
||||
.tab-item svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
|
||||
/* Pages */
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.gpio-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
305
webroot/index.html
Normal file
305
webroot/index.html
Normal file
@@ -0,0 +1,305 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#0a0e17">
|
||||
<title>FlipperDroid</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
|
||||
</div>
|
||||
<h1>FlipperDroid</h1>
|
||||
<p id="status-badge">Detecting Flipper...</p>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-chip" id="chip-conn"><span class="dot off"></span> LINK</div>
|
||||
<div class="status-chip" id="chip-gpio"><span class="dot off"></span> GPIO</div>
|
||||
<div class="status-chip" id="chip-rf"><span class="dot off"></span> RF</div>
|
||||
<div class="status-chip" id="chip-nfc"><span class="dot off"></span> NFC</div>
|
||||
<div class="status-chip" id="chip-cpu"><span class="dot off"></span> CPU</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== DASHBOARD ==================== -->
|
||||
<div class="page active" id="page-dashboard">
|
||||
<div class="section">
|
||||
<div class="section-title">Connection</div>
|
||||
<div class="card">
|
||||
<div class="info-grid">
|
||||
<div class="info-cell"><div class="info-label">Status</div><div class="info-value" id="info-status">-</div></div>
|
||||
<div class="info-cell"><div class="info-label">Transport</div><div class="info-value" id="info-transport">-</div></div>
|
||||
<div class="info-cell"><div class="info-label">Device</div><div class="info-value" id="info-device">-</div></div>
|
||||
<div class="info-cell"><div class="info-label">Product</div><div class="info-value" id="info-product">-</div></div>
|
||||
<div class="info-cell"><div class="info-label">Serial</div><div class="info-value" id="info-serial">-</div></div>
|
||||
<div class="info-cell"><div class="info-label">Firmware</div><div class="info-value" id="info-firmware">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-row">
|
||||
<div class="card-row-info">
|
||||
<div class="card-row-label">Auto-Connect</div>
|
||||
<div class="card-row-desc">Reconnect to Flipper on boot and disconnect</div>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="toggle-autoconnect" onchange="setConfig('AUTO_CONNECT', this.checked?1:0)"><span class="toggle-slider"></span></label>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<div class="card-row-info">
|
||||
<div class="card-row-label">CPU Sharing</div>
|
||||
<div class="card-row-desc">Let Flipper offload compute to phone CPU</div>
|
||||
</div>
|
||||
<label class="toggle"><input type="checkbox" id="toggle-cpushare" onchange="setConfig('CPU_SHARE', this.checked?1:0)"><span class="toggle-slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" onclick="reconnect()">Reconnect</button>
|
||||
<button class="btn btn-secondary" onclick="pingFlipper()">Ping</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== GPIO ==================== -->
|
||||
<div class="page" id="page-gpio">
|
||||
<div class="section">
|
||||
<div class="section-title">GPIO Control</div>
|
||||
<div class="card">
|
||||
<div class="gpio-grid" id="gpio-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="section-title">Quick Actions</div>
|
||||
<div class="card-row">
|
||||
<select id="gpio-pin" class="input-select">
|
||||
<option value="2">PA2</option><option value="3">PA3</option>
|
||||
<option value="4">PA4</option><option value="6">PA6</option>
|
||||
<option value="7">PA7</option><option value="13">PB2</option>
|
||||
<option value="14">PB3</option><option value="15">PB13</option>
|
||||
<option value="16">PB14</option><option value="17">PC0</option>
|
||||
<option value="18">PC1</option><option value="19">PC3</option>
|
||||
</select>
|
||||
<select id="gpio-mode" class="input-select">
|
||||
<option value="0">Input</option>
|
||||
<option value="1">Output Push-Pull</option>
|
||||
<option value="2">Output Open-Drain</option>
|
||||
<option value="3">Analog</option>
|
||||
</select>
|
||||
<button class="btn btn-small" onclick="gpioInit()">Init</button>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<button class="btn btn-small" onclick="gpioRead()">Read</button>
|
||||
<button class="btn btn-small" onclick="gpioWrite(1)">HIGH</button>
|
||||
<button class="btn btn-small btn-danger" onclick="gpioWrite(0)">LOW</button>
|
||||
<span id="gpio-result" class="mono"></span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<label>PWM Freq:</label>
|
||||
<input type="number" id="gpio-pwm-freq" class="input-text" value="1000" min="1" max="1000000">
|
||||
<label>Duty:</label>
|
||||
<input type="number" id="gpio-pwm-duty" class="input-text" value="50" min="0" max="100">
|
||||
<button class="btn btn-small" onclick="gpioPwm()">Set PWM</button>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<button class="btn btn-small" onclick="gpioAdc()">Read ADC</button>
|
||||
<span id="gpio-adc-result" class="mono"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== SubGHz ==================== -->
|
||||
<div class="page" id="page-subghz">
|
||||
<div class="section">
|
||||
<div class="section-title">Sub-GHz Radio</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<label>Frequency (Hz):</label>
|
||||
<input type="number" id="subghz-freq" class="input-text" value="433920000">
|
||||
<button class="btn btn-small" onclick="subghzSetFreq()">Set</button>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<button class="btn" onclick="subghzRxStart()">Start RX</button>
|
||||
<button class="btn btn-danger" onclick="subghzRxStop()">Stop RX</button>
|
||||
<button class="btn btn-secondary" onclick="subghzGetRssi()">RSSI</button>
|
||||
<span id="subghz-rssi" class="mono"></span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<label>TX Data (hex):</label>
|
||||
<input type="text" id="subghz-tx-data" class="input-text" placeholder="deadbeef">
|
||||
<button class="btn btn-small" onclick="subghzTx()">Transmit</button>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<label>Replay slot:</label>
|
||||
<input type="number" id="subghz-replay-slot" class="input-text" value="0" min="0" max="9">
|
||||
<button class="btn btn-small" onclick="subghzReplay()">Replay</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">Captured Signals</div>
|
||||
<div class="card">
|
||||
<div class="log-output" id="subghz-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== NFC/RFID ==================== -->
|
||||
<div class="page" id="page-nfc">
|
||||
<div class="section">
|
||||
<div class="section-title">NFC (13.56 MHz)</div>
|
||||
<div class="card">
|
||||
<button class="btn" onclick="nfcPoll()">Poll Card</button>
|
||||
<button class="btn" onclick="nfcReadFull()">Full Read</button>
|
||||
<button class="btn btn-secondary" onclick="nfcRelayStart()">Start Relay</button>
|
||||
<button class="btn btn-danger" onclick="nfcRelayStop()">Stop Relay</button>
|
||||
<div class="log-output" id="nfc-log"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">RFID (125 kHz)</div>
|
||||
<div class="card">
|
||||
<button class="btn" onclick="rfidRead()">Read Tag</button>
|
||||
<div class="card-row">
|
||||
<label>Emulate UID (hex):</label>
|
||||
<input type="text" id="rfid-emu-data" class="input-text" placeholder="0102030405">
|
||||
<button class="btn btn-small" onclick="rfidEmulate()">Emulate</button>
|
||||
</div>
|
||||
<div class="log-output" id="rfid-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== IR ==================== -->
|
||||
<div class="page" id="page-ir">
|
||||
<div class="section">
|
||||
<div class="section-title">Infrared</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<label>Protocol:</label>
|
||||
<select id="ir-proto" class="input-select">
|
||||
<option value="0">NEC</option><option value="1">Samsung</option>
|
||||
<option value="2">RC5</option><option value="3">RC6</option>
|
||||
<option value="4">SIRC</option><option value="5">Kaseikyo</option>
|
||||
</select>
|
||||
<label>Addr:</label>
|
||||
<input type="number" id="ir-addr" class="input-text" value="0">
|
||||
<label>Cmd:</label>
|
||||
<input type="number" id="ir-cmd" class="input-text" value="0">
|
||||
<button class="btn btn-small" onclick="irTx()">Send</button>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<button class="btn" onclick="irRxStart()">Start Learn</button>
|
||||
<button class="btn btn-danger" onclick="irRxStop()">Stop</button>
|
||||
<label>Replay:</label>
|
||||
<input type="number" id="ir-replay-slot" class="input-text" value="0" min="0" max="9">
|
||||
<button class="btn btn-small" onclick="irReplay()">Replay</button>
|
||||
</div>
|
||||
<div class="log-output" id="ir-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== EVENTS ==================== -->
|
||||
<div class="page" id="page-events">
|
||||
<div class="section">
|
||||
<div class="section-title">Live Events</div>
|
||||
<div class="card">
|
||||
<button class="btn btn-small" onclick="loadEvents()">Refresh</button>
|
||||
<div class="log-output" id="events-log" style="max-height:60vh"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">System Log</div>
|
||||
<div class="card">
|
||||
<button class="btn btn-small" onclick="loadSysLog()">Load Log</button>
|
||||
<div class="log-output" id="sys-log" style="max-height:40vh"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEALTH ==================== -->
|
||||
<div class="page" id="page-stealth">
|
||||
<div class="section">
|
||||
<div class="section-title">Namespace Isolation</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<div class="card-row-info">
|
||||
<div class="card-row-label">Stealth Status</div>
|
||||
<div class="card-row-desc">Bind mounts, port firewall, config hiding</div>
|
||||
</div>
|
||||
<span id="stealth-status-text" class="mono">-</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<button class="btn" onclick="stealthApply()">Apply Stealth</button>
|
||||
<button class="btn btn-danger" onclick="stealthTeardown()">Teardown</button>
|
||||
<button class="btn btn-secondary" onclick="stealthStatus()">Status</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">Quick Controls</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<div class="card-row-info">
|
||||
<div class="card-row-label">Hide Device + Port</div>
|
||||
<div class="card-row-desc">Firewall WebUI to localhost, restrict config perms</div>
|
||||
</div>
|
||||
<button class="btn btn-small" onclick="stealthHideDev()">Hide</button>
|
||||
<button class="btn btn-small" onclick="stealthShowDev()">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">Stealth Map</div>
|
||||
<div class="card">
|
||||
<div class="card-row-desc" style="padding:8px">
|
||||
Edit <code>/data/adb/flipperdroid/stealth_map.conf</code> to configure per-process bind mount isolation.
|
||||
Stock files stay untouched. Only target processes see modifications.
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<label>Active bind mounts:</label>
|
||||
<span id="stealth-mount-count" class="mono">-</span>
|
||||
</div>
|
||||
<div class="log-output" id="stealth-log"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="tab-bar">
|
||||
<div class="tab-item active" data-tab="dashboard" onclick="switchTab('dashboard')">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
|
||||
<span>Home</span>
|
||||
</div>
|
||||
<div class="tab-item" data-tab="gpio" onclick="switchTab('gpio')">
|
||||
<svg viewBox="0 0 24 24"><path d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
|
||||
<span>GPIO</span>
|
||||
</div>
|
||||
<div class="tab-item" data-tab="subghz" onclick="switchTab('subghz')">
|
||||
<svg viewBox="0 0 24 24"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
|
||||
<span>SubGHz</span>
|
||||
</div>
|
||||
<div class="tab-item" data-tab="nfc" onclick="switchTab('nfc')">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 18H4V4h16v16zM6 6h2v2H6zm0 4h2v2H6zm0 4h2v2H6zm4-8h8v2h-8zm0 4h8v2h-8zm0 4h8v2h-8z"/></svg>
|
||||
<span>NFC</span>
|
||||
</div>
|
||||
<div class="tab-item" data-tab="ir" onclick="switchTab('ir')">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM9 8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
|
||||
<span>IR</span>
|
||||
</div>
|
||||
<div class="tab-item" data-tab="stealth" onclick="switchTab('stealth')">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
||||
<span>Stealth</span>
|
||||
</div>
|
||||
<div class="tab-item" data-tab="events" onclick="switchTab('events')">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></svg>
|
||||
<span>Log</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
376
webroot/js/app.js
Normal file
376
webroot/js/app.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// FlipperDroid WebUI — Bridge Controller
|
||||
|
||||
let config = {};
|
||||
let currentTab = 'dashboard';
|
||||
let toastTimer = null;
|
||||
let eventPollInterval = null;
|
||||
|
||||
// ---- API ----
|
||||
async function api(path, opts = {}) {
|
||||
try {
|
||||
const res = await fetch(path, opts);
|
||||
if (path.endsWith('.css') || path.endsWith('.js') || path.endsWith('.html')) return res;
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('json')) return await res.json();
|
||||
return await res.text();
|
||||
} catch (e) {
|
||||
showToast('Connection error', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const post = (path, body) => api(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
// ---- Toast ----
|
||||
function showToast(msg, type = 'success') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast ' + type + ' show';
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.className = 'toast', 2500);
|
||||
}
|
||||
|
||||
// ---- Tabs ----
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-item').forEach(t => t.classList.remove('active'));
|
||||
document.getElementById('page-' + tab).classList.add('active');
|
||||
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
|
||||
if (tab === 'events') loadEvents();
|
||||
if (tab === 'gpio') initGpioGrid();
|
||||
if (tab === 'stealth') stealthStatus();
|
||||
}
|
||||
|
||||
// ---- Dashboard ----
|
||||
async function loadStatus() {
|
||||
const data = await api('/api/status');
|
||||
if (!data) return;
|
||||
|
||||
config = data;
|
||||
setText('info-status', data.status || 'unknown');
|
||||
setText('info-transport', data.conn_type || 'none');
|
||||
setText('info-device', data.device || '-');
|
||||
setText('info-product', data.product || '-');
|
||||
setText('info-serial', data.serial || '-');
|
||||
setText('info-firmware', data.firmware_version || '-');
|
||||
|
||||
const badge = document.getElementById('status-badge');
|
||||
if (data.status === 'connected') {
|
||||
badge.textContent = `Connected — ${data.conn_type?.toUpperCase() || 'USB'} — ${data.product || 'Flipper Zero'}`;
|
||||
} else {
|
||||
badge.textContent = 'Disconnected';
|
||||
}
|
||||
|
||||
// Status chips
|
||||
setChip('chip-conn', data.status === 'connected');
|
||||
setChip('chip-gpio', data.status === 'connected' && data.config?.enable_gpio);
|
||||
setChip('chip-rf', data.status === 'connected' && data.config?.enable_subghz);
|
||||
setChip('chip-nfc', data.status === 'connected' && data.config?.enable_nfc);
|
||||
setChip('chip-cpu', data.status === 'connected' && data.config?.cpu_share);
|
||||
|
||||
// Toggles
|
||||
if (data.config) {
|
||||
setToggle('toggle-autoconnect', data.config.auto_connect);
|
||||
setToggle('toggle-cpushare', data.config.cpu_share);
|
||||
}
|
||||
}
|
||||
|
||||
function setChip(id, on) {
|
||||
const chip = document.getElementById(id);
|
||||
if (!chip) return;
|
||||
const dot = chip.querySelector('.dot');
|
||||
if (dot) { dot.className = on ? 'dot' : 'dot off'; }
|
||||
}
|
||||
|
||||
function setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function setToggle(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.checked = !!val;
|
||||
}
|
||||
|
||||
async function setConfig(key, value) {
|
||||
const r = await post('/api/config', { key, value: String(value) });
|
||||
if (r && r.ok) showToast('Config updated');
|
||||
else showToast('Config update failed', 'error');
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
await post('/api/reconnect');
|
||||
showToast('Reconnecting...');
|
||||
setTimeout(loadStatus, 3000);
|
||||
}
|
||||
|
||||
async function pingFlipper() {
|
||||
const r = await post('/api/system/ping');
|
||||
if (r && r.result && r.result.startsWith('OK')) showToast('Pong!');
|
||||
else showToast('Ping failed: ' + (r?.result || 'no response'), 'error');
|
||||
}
|
||||
|
||||
// ---- GPIO ----
|
||||
const GPIO_PINS = [
|
||||
{ num: 2, name: 'PA2' }, { num: 3, name: 'PA3' }, { num: 4, name: 'PA4' },
|
||||
{ num: 6, name: 'PA6' }, { num: 7, name: 'PA7' }, { num: 13, name: 'PB2' },
|
||||
{ num: 14, name: 'PB3' }, { num: 15, name: 'PB13' }, { num: 16, name: 'PB14' },
|
||||
{ num: 17, name: 'PC0' }, { num: 18, name: 'PC1' }, { num: 19, name: 'PC3' }
|
||||
];
|
||||
|
||||
function initGpioGrid() {
|
||||
const grid = document.getElementById('gpio-grid');
|
||||
if (!grid || grid.children.length > 0) return;
|
||||
GPIO_PINS.forEach(pin => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'gpio-pin';
|
||||
div.id = 'gpio-pin-' + pin.num;
|
||||
div.innerHTML = `<div class="pin-name">${pin.name}</div><div class="pin-val">-</div>`;
|
||||
div.onclick = () => quickGpioRead(pin.num);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function quickGpioRead(pinNum) {
|
||||
const r = await post('/api/gpio/read', { pin: pinNum });
|
||||
const el = document.getElementById('gpio-pin-' + pinNum);
|
||||
if (r && r.result && r.result.startsWith('OK:')) {
|
||||
const val = r.result.replace('OK:', '');
|
||||
const valEl = el.querySelector('.pin-val');
|
||||
valEl.textContent = val === '01' || val === '1' ? 'HIGH' : 'LOW';
|
||||
el.className = 'gpio-pin ' + (val === '01' || val === '1' ? 'high' : 'low');
|
||||
}
|
||||
}
|
||||
|
||||
async function gpioInit() {
|
||||
const pin = parseInt(document.getElementById('gpio-pin').value);
|
||||
const mode = parseInt(document.getElementById('gpio-mode').value);
|
||||
const r = await post('/api/gpio/init', { pin, mode });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function gpioRead() {
|
||||
const pin = parseInt(document.getElementById('gpio-pin').value);
|
||||
const r = await post('/api/gpio/read', { pin });
|
||||
document.getElementById('gpio-result').textContent = r?.result || '-';
|
||||
}
|
||||
|
||||
async function gpioWrite(val) {
|
||||
const pin = parseInt(document.getElementById('gpio-pin').value);
|
||||
const r = await post('/api/gpio/write', { pin, value: val });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function gpioPwm() {
|
||||
const pin = parseInt(document.getElementById('gpio-pin').value);
|
||||
const freq = parseInt(document.getElementById('gpio-pwm-freq').value);
|
||||
const duty = parseInt(document.getElementById('gpio-pwm-duty').value);
|
||||
const r = await post('/api/gpio/pwm', { pin, freq, duty });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function gpioAdc() {
|
||||
const pin = parseInt(document.getElementById('gpio-pin').value);
|
||||
const r = await post('/api/gpio/adc', { pin });
|
||||
document.getElementById('gpio-adc-result').textContent = r?.result || '-';
|
||||
}
|
||||
|
||||
// ---- SubGHz ----
|
||||
async function subghzSetFreq() {
|
||||
const freq = parseInt(document.getElementById('subghz-freq').value);
|
||||
const r = await post('/api/subghz/set_freq', { freq });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function subghzTx() {
|
||||
const data = document.getElementById('subghz-tx-data').value;
|
||||
const r = await post('/api/subghz/tx', { data });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function subghzRxStart() {
|
||||
const r = await post('/api/subghz/rx_start');
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function subghzRxStop() {
|
||||
const r = await post('/api/subghz/rx_stop');
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function subghzGetRssi() {
|
||||
const r = await post('/api/subghz/get_rssi');
|
||||
document.getElementById('subghz-rssi').textContent = r?.result || '-';
|
||||
}
|
||||
|
||||
async function subghzReplay() {
|
||||
const slot = parseInt(document.getElementById('subghz-replay-slot').value);
|
||||
const r = await post('/api/subghz/replay', { slot });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
// ---- NFC ----
|
||||
async function nfcPoll() {
|
||||
appendLog('nfc-log', 'Polling for NFC card...');
|
||||
const r = await post('/api/nfc/poll');
|
||||
appendLog('nfc-log', r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function nfcReadFull() {
|
||||
appendLog('nfc-log', 'Reading full card...');
|
||||
const r = await post('/api/nfc/read_full');
|
||||
appendLog('nfc-log', r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function nfcRelayStart() {
|
||||
const r = await post('/api/nfc/relay_start');
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function nfcRelayStop() {
|
||||
const r = await post('/api/nfc/relay_stop');
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
// ---- RFID ----
|
||||
async function rfidRead() {
|
||||
appendLog('rfid-log', 'Reading RFID tag...');
|
||||
const r = await post('/api/rfid/read');
|
||||
appendLog('rfid-log', r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function rfidEmulate() {
|
||||
const data = document.getElementById('rfid-emu-data').value;
|
||||
const r = await post('/api/rfid/emulate', { data });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
// ---- IR ----
|
||||
async function irTx() {
|
||||
const protocol = parseInt(document.getElementById('ir-proto').value);
|
||||
const address = parseInt(document.getElementById('ir-addr').value);
|
||||
const command = parseInt(document.getElementById('ir-cmd').value);
|
||||
const r = await post('/api/ir/tx', { protocol, address, command });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function irRxStart() {
|
||||
const r = await post('/api/ir/rx_start');
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function irRxStop() {
|
||||
const r = await post('/api/ir/rx_stop');
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
async function irReplay() {
|
||||
const slot = parseInt(document.getElementById('ir-replay-slot').value);
|
||||
const r = await post('/api/ir/replay', { slot });
|
||||
showToast(r?.result || 'No response');
|
||||
}
|
||||
|
||||
// ---- Stealth ----
|
||||
async function stealthStatus() {
|
||||
const r = await api('/api/stealth/status');
|
||||
const el = document.getElementById('stealth-status-text');
|
||||
const countEl = document.getElementById('stealth-mount-count');
|
||||
const logEl = document.getElementById('stealth-log');
|
||||
if (r && typeof r === 'object') {
|
||||
el.textContent = r.active_bind_mounts > 0 ? 'ACTIVE' : 'INACTIVE';
|
||||
el.style.color = r.active_bind_mounts > 0 ? 'var(--success)' : 'var(--text-muted)';
|
||||
countEl.textContent = r.active_bind_mounts || '0';
|
||||
if (logEl) {
|
||||
logEl.textContent = JSON.stringify(r, null, 2);
|
||||
}
|
||||
} else {
|
||||
el.textContent = 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
async function stealthApply() {
|
||||
const r = await post('/api/stealth/apply');
|
||||
showToast(r?.message || 'Stealth applied');
|
||||
setTimeout(stealthStatus, 1000);
|
||||
}
|
||||
|
||||
async function stealthTeardown() {
|
||||
const r = await post('/api/stealth/teardown');
|
||||
showToast(r?.message || 'Stealth removed');
|
||||
setTimeout(stealthStatus, 1000);
|
||||
}
|
||||
|
||||
async function stealthHideDev() {
|
||||
const r = await post('/api/stealth/hide');
|
||||
showToast(r?.message || 'Device hidden');
|
||||
setTimeout(stealthStatus, 1000);
|
||||
}
|
||||
|
||||
async function stealthShowDev() {
|
||||
const r = await post('/api/stealth/show');
|
||||
showToast(r?.message || 'Device visible');
|
||||
setTimeout(stealthStatus, 1000);
|
||||
}
|
||||
|
||||
// ---- Events ----
|
||||
async function loadEvents() {
|
||||
const data = await api('/api/events');
|
||||
const log = document.getElementById('events-log');
|
||||
if (!log) return;
|
||||
if (Array.isArray(data)) {
|
||||
log.textContent = data.map(e =>
|
||||
`[${new Date(e.timestamp * 1000).toLocaleTimeString()}] ${e.type}: ${e.data} ${e.extra || ''}`
|
||||
).join('\n') || 'No events yet';
|
||||
} else {
|
||||
log.textContent = data || 'No events';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSysLog() {
|
||||
const data = await api('/api/log');
|
||||
const log = document.getElementById('sys-log');
|
||||
if (log) log.textContent = data || 'No log data';
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
function appendLog(id, msg) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
el.textContent += `[${ts}] ${msg}\n`;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStatus();
|
||||
setInterval(loadStatus, 5000);
|
||||
|
||||
// Poll events when on events tab
|
||||
setInterval(() => {
|
||||
if (currentTab === 'events') loadEvents();
|
||||
}, 3000);
|
||||
|
||||
// Poll SubGHz log when on subghz tab
|
||||
setInterval(async () => {
|
||||
if (currentTab === 'subghz') {
|
||||
const data = await api('/api/events');
|
||||
if (Array.isArray(data)) {
|
||||
const rfEvents = data.filter(e => e.type === 'subghz_rx');
|
||||
if (rfEvents.length > 0) {
|
||||
const log = document.getElementById('subghz-log');
|
||||
if (log) {
|
||||
log.textContent = rfEvents.map(e =>
|
||||
`[${new Date(e.timestamp * 1000).toLocaleTimeString()}] RX: ${e.data} (${e.extra})`
|
||||
).join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
Reference in New Issue
Block a user