Wifi capture base

This commit is contained in:
MrARM
2026-05-29 00:29:14 -05:00
parent 9c26aa940c
commit 3954b06f85
9 changed files with 1419 additions and 20 deletions

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
captures/
.DS_Store
*.pyc

View File

@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x300000,
spiffs, data, spiffs, 0x310000,0xE0000,
coredump, data, coredump,0x3F0000,0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x300000
5 spiffs data spiffs 0x310000 0xE0000
6 coredump data coredump 0x3F0000 0x10000

View File

@@ -12,5 +12,8 @@
platform = platformio/espressif32@6.4.0
board = ttgo-t-watch
framework = arduino
board_build.filesystem = littlefs
board_build.partitions = partitions_captainjack.csv
monitor_speed = 115200
lib_deps =
0ldev/Politician@^1.0.0

View File

@@ -1,32 +1,18 @@
#include "user_app.h"
#include "wifi_app.h"
void userAppSetup(TTGOClass *watch)
{
(void)watch;
// Add one-time setup for your application here.
wifiAppSetup(watch);
}
void userAppLoop()
{
// Add recurring application work here. Keep this quick so LVGL stays smooth.
wifiAppLoop();
}
void userAppBuildMenu(lv_obj_t *menuList)
{
(void)menuList;
/*
Add custom LVGL menu entries here, for example:
lv_obj_t *item = lv_list_add_btn(menuList, NULL, "My Action");
lv_obj_set_event_cb(item, [](lv_obj_t *obj, lv_event_t event) {
(void)obj;
if (event == LV_EVENT_CLICKED) {
watchFrameworkRefreshActivity();
// Run your action here.
}
});
*/
wifiAppBuildMenu(menuList);
}

View File

@@ -20,6 +20,7 @@ enum class WatchState {
WATCH_FACE,
MENU,
CLOCK_SETTINGS,
ALERT,
SLEEPING
};
@@ -58,6 +59,8 @@ uint32_t gSleepDeadline = 0;
uint32_t gLastClockRefresh = 0;
uint32_t gLastClickMs = 0;
uint8_t gPendingClicks = 0;
uint8_t gVibrationPulsesRemaining = 0;
uint32_t gNextVibrationMs = 0;
char gTimeZoneName[32] = "UTC";
@@ -74,6 +77,8 @@ uint8_t gDraftMinute = 0;
uint8_t gDraftTimeZone = 0;
SettingsField gActiveField = SettingsField::YEAR;
void updateSleepDeadline();
void IRAM_ATTR onAxpInterrupt()
{
gAxpIrq = true;
@@ -191,6 +196,45 @@ void wakeDisplay()
gWatch->setBrightness(SCREEN_BRIGHTNESS);
}
void drawAlert(const char *titleText, const char *line1Text, const char *line2Text, const char *line3Text)
{
wakeDisplay();
setScreenBase(LV_COLOR_BLACK);
lv_obj_t *title = lv_label_create(lv_scr_act(), NULL);
lv_label_set_text(title, titleText ? titleText : "Alert");
lv_obj_set_style_local_text_color(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_YELLOW);
lv_obj_set_style_local_text_font(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, &lv_font_montserrat_28);
lv_obj_align(title, NULL, LV_ALIGN_IN_TOP_MID, 0, 22);
lv_obj_t *line1 = lv_label_create(lv_scr_act(), NULL);
lv_label_set_long_mode(line1, LV_LABEL_LONG_BREAK);
lv_obj_set_width(line1, 220);
lv_label_set_align(line1, LV_LABEL_ALIGN_CENTER);
lv_label_set_text(line1, line1Text ? line1Text : "");
lv_obj_set_style_local_text_color(line1, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_WHITE);
lv_obj_align(line1, title, LV_ALIGN_OUT_BOTTOM_MID, 0, 18);
lv_obj_t *line2 = lv_label_create(lv_scr_act(), NULL);
lv_label_set_long_mode(line2, LV_LABEL_LONG_BREAK);
lv_obj_set_width(line2, 220);
lv_label_set_align(line2, LV_LABEL_ALIGN_CENTER);
lv_label_set_text(line2, line2Text ? line2Text : "");
lv_obj_set_style_local_text_color(line2, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_SILVER);
lv_obj_align(line2, line1, LV_ALIGN_OUT_BOTTOM_MID, 0, 10);
lv_obj_t *line3 = lv_label_create(lv_scr_act(), NULL);
lv_label_set_long_mode(line3, LV_LABEL_LONG_BREAK);
lv_obj_set_width(line3, 220);
lv_label_set_align(line3, LV_LABEL_ALIGN_CENTER);
lv_label_set_text(line3, line3Text ? line3Text : "");
lv_obj_set_style_local_text_color(line3, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_SILVER);
lv_obj_align(line3, line2, LV_ALIGN_OUT_BOTTOM_MID, 0, 10);
updateSleepDeadline();
gState = WatchState::ALERT;
}
void updateSleepDeadline()
{
gSleepDeadline = millis() + SCREEN_TIMEOUT_MS;
@@ -605,6 +649,24 @@ void configurePowerButton()
gWatch->power->clearIRQ();
}
void handleVibration()
{
if (gVibrationPulsesRemaining == 0 || !gWatch) {
return;
}
uint32_t now = millis();
if (static_cast<int32_t>(now - gNextVibrationMs) < 0) {
return;
}
#ifdef LILYGO_WATCH_HAS_MOTOR
gWatch->shake();
#endif
--gVibrationPulsesRemaining;
gNextVibrationMs = now + 180;
}
void configureBatteryAdc()
{
gWatch->power->adc1Enable(
@@ -666,6 +728,12 @@ void handleTimeouts()
return;
}
if (gState == WatchState::ALERT &&
static_cast<int32_t>(now - gSleepDeadline) >= 0) {
watchFrameworkSleep();
return;
}
if (gState == WatchState::WATCH_FACE && now - gLastClockRefresh >= 1000) {
updateWatchFace();
gLastClockRefresh = now;
@@ -681,12 +749,16 @@ void watchFrameworkSetup(TTGOClass *watch)
loadSettings();
configureBatteryAdc();
configurePowerButton();
#ifdef LILYGO_WATCH_HAS_MOTOR
gWatch->motor_begin();
#endif
drawBootConfirmation();
}
void watchFrameworkLoop()
{
handleAxpInterrupt();
handleVibration();
handleTimeouts();
}
@@ -722,6 +794,22 @@ void watchFrameworkRefreshActivity()
updateSleepDeadline();
}
bool watchFrameworkIsSleeping()
{
return gState == WatchState::SLEEPING;
}
void watchFrameworkShowAlert(const char *title, const char *line1, const char *line2, const char *line3)
{
drawAlert(title, line1, line2, line3);
}
void watchFrameworkVibrateDouble()
{
gVibrationPulsesRemaining = 2;
gNextVibrationMs = 0;
}
TTGOClass *watchFrameworkWatch()
{
return gWatch;

View File

@@ -18,6 +18,8 @@ void watchFrameworkWakeFace();
void watchFrameworkOpenMenu();
void watchFrameworkSleep();
void watchFrameworkRefreshActivity();
bool watchFrameworkIsSleeping();
void watchFrameworkShowAlert(const char *title, const char *line1, const char *line2, const char *line3);
void watchFrameworkVibrateDouble();
TTGOClass *watchFrameworkWatch();

1039
src/wifi_app.cpp Normal file

File diff suppressed because it is too large Load Diff

7
src/wifi_app.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#include "watch_framework.h"
void wifiAppSetup(TTGOClass *watch);
void wifiAppLoop();
void wifiAppBuildMenu(lv_obj_t *menuList);

265
tools/capture_export.py Executable file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""Download CaptainJack capture files from the watch over USB serial."""
from __future__ import annotations
import argparse
import binascii
import datetime as dt
import sys
import time
from pathlib import Path
try:
import serial
from serial.tools import list_ports
except ImportError: # pragma: no cover - dependency error path
serial = None
list_ports = None
BAUD = 115200
MARKER = "@WS "
CAPTURE_FILES = ("captures.pcapng", "captures.22000")
class ProtocolError(RuntimeError):
pass
def require_serial() -> None:
if serial is None:
raise SystemExit("pyserial is required: python3 -m pip install pyserial")
def open_serial(port: str, timeout: float = 3.0):
require_serial()
ser = serial.Serial()
ser.port = port
ser.baudrate = BAUD
ser.timeout = timeout
ser.write_timeout = timeout
ser.dtr = False
ser.rts = False
ser.open()
ser.dtr = False
ser.rts = False
time.sleep(0.15)
ser.reset_input_buffer()
return ser
def protocol_line(ser, timeout: float = 8.0) -> str:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
text = raw.decode("utf-8", "replace").strip()
pos = text.find(MARKER)
if pos >= 0:
return text[pos:]
raise TimeoutError("timed out waiting for @WS response")
def send(ser, command: str) -> None:
ser.write((command.strip() + "\n").encode("ascii"))
ser.flush()
def ping(ser) -> bool:
for _ in range(3):
send(ser, "PING")
try:
line = protocol_line(ser, timeout=4.0)
except TimeoutError:
continue
if line == "@WS READY":
send(ser, "PING")
continue
if line == "@WS PONG":
return True
return False
def list_captures(ser) -> list[tuple[str, int]]:
send(ser, "LIST")
files: list[tuple[str, int]] = []
saw_begin = False
while True:
line = protocol_line(ser)
if line == "@WS LIST BEGIN":
saw_begin = True
continue
if line == "@WS LIST END":
return files
if line.startswith("@WS ERR "):
raise ProtocolError(line)
if line.startswith("@WS FILE "):
if not saw_begin:
raise ProtocolError("FILE before LIST BEGIN")
parts = line.split(" ", 3)
if len(parts) != 4:
raise ProtocolError(f"bad FILE line: {line}")
files.append((parts[2], int(parts[3])))
def download_one(ser, name: str, out_dir: Path) -> Path:
send(ser, f"GET {name}")
first = protocol_line(ser)
if first.startswith("@WS ERR "):
raise ProtocolError(first)
parts = first.split(" ", 3)
if len(parts) != 4 or parts[:2] != ["@WS", "BEGIN"]:
raise ProtocolError(f"bad BEGIN line: {first}")
remote_name = parts[2]
expected_size = int(parts[3])
data = bytearray()
while True:
line = protocol_line(ser, timeout=12.0)
if line.startswith("@WS DATA "):
try:
data.extend(binascii.unhexlify(line[9:]))
except binascii.Error as exc:
raise ProtocolError(f"bad DATA hex for {remote_name}") from exc
continue
if line == f"@WS END {remote_name}":
break
if line.startswith("@WS ERR "):
raise ProtocolError(line)
raise ProtocolError(f"unexpected line while downloading {remote_name}: {line}")
if len(data) != expected_size:
raise ProtocolError(f"{remote_name}: expected {expected_size} bytes, got {len(data)}")
out_path = out_dir / remote_name
out_path.write_bytes(data)
return out_path
def clear_watch(ser) -> str:
send(ser, "CLEAR")
line = protocol_line(ser)
if line.startswith("@WS OK "):
return line[7:]
raise ProtocolError(line)
def yes_no(prompt: str) -> bool:
answer = input(f"{prompt} [y/N] ").strip().lower()
return answer in {"y", "yes"}
def default_output_dir() -> Path:
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
return Path("captures") / stamp
def cmd_ports(_args) -> int:
require_serial()
ports = list(list_ports.comports())
if not ports:
print("No serial ports found.")
return 1
for port in ports:
desc = f" - {port.description}" if port.description else ""
print(f"{port.device}{desc}")
return 0
def cmd_ping(args) -> int:
with open_serial(args.port) as ser:
if ping(ser):
print("Watch responded: PONG")
return 0
print("No PONG from watch.")
return 1
def cmd_list(args) -> int:
with open_serial(args.port) as ser:
files = list_captures(ser)
if not files:
print("No captures stored.")
return 0
for name, size in files:
print(f"{name}\t{size} bytes")
return 0
def cmd_download(args) -> int:
out_dir = args.output
out_dir.mkdir(parents=True, exist_ok=True)
with open_serial(args.port) as ser:
if not ping(ser):
raise SystemExit("watch did not respond to PING")
files = list_captures(ser)
if not files:
print("No captures stored.")
return 0
downloaded: list[Path] = []
for name, size in files:
if size <= 0:
continue
path = download_one(ser, name, out_dir)
downloaded.append(path)
print(f"Downloaded {name} -> {path} ({path.stat().st_size} bytes)")
if downloaded and not args.no_clean:
if yes_no("Clear downloaded captures from the watch now?"):
result = clear_watch(ser)
print(f"Watch cleanup: {result}")
else:
print("Left captures on watch.")
return 0
def cmd_clear(args) -> int:
if not args.yes and not yes_no("Delete capture files and captured-BSSID cache from the watch?"):
print("Cancelled.")
return 0
with open_serial(args.port) as ser:
result = clear_watch(ser)
print(f"Watch cleanup: {result}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("ports", help="list available serial ports").set_defaults(func=cmd_ports)
for name, help_text in (
("ping", "check watch protocol response"),
("list", "list capture files stored on the watch"),
("download", "download capture files and optionally clear them"),
("clear", "delete capture files from the watch"),
):
p = sub.add_parser(name, help=help_text)
p.add_argument("--port", required=True, help="serial port, e.g. /dev/cu.usbserial-...")
if name == "download":
p.add_argument("-o", "--output", type=Path, default=default_output_dir())
p.add_argument("--no-clean", action="store_true", help="do not ask to clear after download")
if name == "clear":
p.add_argument("-y", "--yes", action="store_true", help="clear without prompting")
p.set_defaults(func=globals()[f"cmd_{name}"])
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return args.func(args)
except (TimeoutError, ProtocolError, serial.SerialException if serial else Exception) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())