Normalize line endings to LF across entire repository

Apply .gitattributes normalization to convert all CRLF line
endings inherited from Windows-origin source files to Unix LF.
175 files, zero content changes.
This commit is contained in:
Ryan Malloy 2026-02-20 10:55:50 -07:00
parent 696d2dd387
commit bbdcb243dc
175 changed files with 56794 additions and 56794 deletions

View file

@ -1,38 +1,38 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "skywalker-tui"
version = "0.1.0"
description = "Textual TUI for Genpix SkyWalker-1 DVB-S receiver"
requires-python = ">=3.11"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
dependencies = [
"textual>=3.0",
"pyusb>=1.3",
]
[project.scripts]
skywalker-tui = "skywalker_tui.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/skywalker_tui"]
artifacts = ["src/skywalker_tui/assets/**"]
[project.optional-dependencies]
dev = [
"Pillow>=10.0",
]
test = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py311"
line-length = 100
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "skywalker-tui"
version = "0.1.0"
description = "Textual TUI for Genpix SkyWalker-1 DVB-S receiver"
requires-python = ">=3.11"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
dependencies = [
"textual>=3.0",
"pyusb>=1.3",
]
[project.scripts]
skywalker-tui = "skywalker_tui.app:main"
[tool.hatch.build.targets.wheel]
packages = ["src/skywalker_tui"]
artifacts = ["src/skywalker_tui/assets/**"]
[project.optional-dependencies]
dev = [
"Pillow>=10.0",
]
test = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py311"
line-length = 100

View file

@ -1,99 +1,99 @@
#!/usr/bin/env python3
"""Download splash art from 16colo.rs for SkyWalker-1 TUI.
Fetches pixel/teletext art from Mistigris packs on 16colo.rs and saves
them as bundled assets for the splash screen.
Usage:
python scripts/fetch_art.py
"""
import urllib.request
import sys
from pathlib import Path
ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash"
ART_SOURCES = [
{
"url": "https://16colo.rs/pack/mist0717/raw/ILLARTERATE-SETI-100_02_SATELLITE.PNG",
"filename": "seti-satellite.png",
"artist": "Illarterate",
"title": "S.E.T.I. Satellite",
"pack": "mist0717",
},
{
"url": "https://16colo.rs/pack/mist1119/raw/192.168.10.13-DIALTONE.JPG",
"filename": "dialtone.jpg",
"artist": "192.168.10.13",
"title": "Dialtone",
"pack": "mist1119",
},
{
"url": "https://16colo.rs/pack/mist0523/raw/BLIPPYPIXEL-SO_FAR_AWAY.GIF",
"filename": "so-far-away.gif",
"artist": "Blippypixel",
"title": "So Far Away",
"pack": "mist0523",
},
{
"url": "https://16colo.rs/pack/mist0121/raw/JELLICA_JAKE-PRODIGY.JPG",
"filename": "prodigy-out-of-space.jpg",
"artist": "Jellica Jake",
"title": "Prodigy / Out of Space",
"pack": "mist0121",
},
{
"url": "https://16colo.rs/pack/mist1120/raw/BLIPPYPIXEL-SPACE_DOCKER.GIF",
"filename": "space-docker.gif",
"artist": "Blippypixel",
"title": "Space Docker",
"pack": "mist1120",
},
]
def fetch_all():
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
downloaded = 0
for art in ART_SOURCES:
dest = ASSETS_DIR / art["filename"]
if dest.exists():
print(f" exists: {art['filename']}", file=sys.stderr)
downloaded += 1
continue
print(f" fetch: {art['filename']} from {art['url']}", file=sys.stderr)
try:
req = urllib.request.Request(art["url"], headers={"User-Agent": "SkyWalker-1-TUI/0.1"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
dest.write_bytes(data)
print(f" {len(data):,} bytes -> {dest.name}", file=sys.stderr)
downloaded += 1
except Exception as e:
print(f" FAIL: {art['filename']}: {e}", file=sys.stderr)
# Write CREDITS.md
credits = ASSETS_DIR / "CREDITS.md"
lines = ["# Splash Art Credits\n", ""]
lines.append("All artwork sourced from [16colo.rs](https://16colo.rs) /")
lines.append("[Mistigris](https://mistigris.com) art packs.\n")
lines.append("| File | Artist | Title | Pack |")
lines.append("|------|--------|-------|------|")
for art in ART_SOURCES:
pack_url = f"https://16colo.rs/pack/{art['pack']}"
lines.append(
f"| `{art['filename']}` | {art['artist']} "
f"| {art['title']} | [{art['pack']}]({pack_url}) |"
)
lines.append("")
credits.write_text("\n".join(lines))
print(f"\n {downloaded}/{len(ART_SOURCES)} images downloaded to {ASSETS_DIR}", file=sys.stderr)
return downloaded
if __name__ == "__main__":
fetch_all()
#!/usr/bin/env python3
"""Download splash art from 16colo.rs for SkyWalker-1 TUI.
Fetches pixel/teletext art from Mistigris packs on 16colo.rs and saves
them as bundled assets for the splash screen.
Usage:
python scripts/fetch_art.py
"""
import urllib.request
import sys
from pathlib import Path
ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash"
ART_SOURCES = [
{
"url": "https://16colo.rs/pack/mist0717/raw/ILLARTERATE-SETI-100_02_SATELLITE.PNG",
"filename": "seti-satellite.png",
"artist": "Illarterate",
"title": "S.E.T.I. Satellite",
"pack": "mist0717",
},
{
"url": "https://16colo.rs/pack/mist1119/raw/192.168.10.13-DIALTONE.JPG",
"filename": "dialtone.jpg",
"artist": "192.168.10.13",
"title": "Dialtone",
"pack": "mist1119",
},
{
"url": "https://16colo.rs/pack/mist0523/raw/BLIPPYPIXEL-SO_FAR_AWAY.GIF",
"filename": "so-far-away.gif",
"artist": "Blippypixel",
"title": "So Far Away",
"pack": "mist0523",
},
{
"url": "https://16colo.rs/pack/mist0121/raw/JELLICA_JAKE-PRODIGY.JPG",
"filename": "prodigy-out-of-space.jpg",
"artist": "Jellica Jake",
"title": "Prodigy / Out of Space",
"pack": "mist0121",
},
{
"url": "https://16colo.rs/pack/mist1120/raw/BLIPPYPIXEL-SPACE_DOCKER.GIF",
"filename": "space-docker.gif",
"artist": "Blippypixel",
"title": "Space Docker",
"pack": "mist1120",
},
]
def fetch_all():
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
downloaded = 0
for art in ART_SOURCES:
dest = ASSETS_DIR / art["filename"]
if dest.exists():
print(f" exists: {art['filename']}", file=sys.stderr)
downloaded += 1
continue
print(f" fetch: {art['filename']} from {art['url']}", file=sys.stderr)
try:
req = urllib.request.Request(art["url"], headers={"User-Agent": "SkyWalker-1-TUI/0.1"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = resp.read()
dest.write_bytes(data)
print(f" {len(data):,} bytes -> {dest.name}", file=sys.stderr)
downloaded += 1
except Exception as e:
print(f" FAIL: {art['filename']}: {e}", file=sys.stderr)
# Write CREDITS.md
credits = ASSETS_DIR / "CREDITS.md"
lines = ["# Splash Art Credits\n", ""]
lines.append("All artwork sourced from [16colo.rs](https://16colo.rs) /")
lines.append("[Mistigris](https://mistigris.com) art packs.\n")
lines.append("| File | Artist | Title | Pack |")
lines.append("|------|--------|-------|------|")
for art in ART_SOURCES:
pack_url = f"https://16colo.rs/pack/{art['pack']}"
lines.append(
f"| `{art['filename']}` | {art['artist']} "
f"| {art['title']} | [{art['pack']}]({pack_url}) |"
)
lines.append("")
credits.write_text("\n".join(lines))
print(f"\n {downloaded}/{len(ART_SOURCES)} images downloaded to {ASSETS_DIR}", file=sys.stderr)
return downloaded
if __name__ == "__main__":
fetch_all()

View file

@ -1,247 +1,247 @@
#!/usr/bin/env python3
"""Generate SVG screenshots of every TUI screen for documentation.
Uses Textual's headless run_test() + Pilot API to programmatically navigate
each screen and export SVG renders. Requires no hardware runs entirely
with DemoDevice synthetic signal data.
Output: ../site/src/assets/tui/*.svg (13 screenshots)
Usage:
cd tui && uv run python scripts/generate_screenshots.py
"""
import asyncio
import sys
from pathlib import Path
# Ensure the src layout is importable when running from scripts/
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from skywalker_tui.app import SkyWalkerApp
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
OUTPUT_DIR = Path(__file__).resolve().parent.parent.parent / "site" / "src" / "assets" / "tui"
# Terminal size for screenshots — wide enough for sidebar + content
TERM_SIZE = (120, 36)
# Pause durations for rendering
MOUNT_PAUSE = 1.5 # initial mount + mode screen init
MODE_SWITCH_PAUSE = 0.8 # after F-key press
NOTIFY_PAUSE = 0.6 # for toast notifications
STARWARS_PAUSE = 12.0 # time for offline crawl to reach Star Destroyer frame
def _new_app(**kwargs) -> SkyWalkerApp:
"""Create a fresh app instance with demo device."""
return SkyWalkerApp(bridge=USBBridge(DemoDevice()), **kwargs)
def _save(svg: str, name: str) -> None:
path = OUTPUT_DIR / f"{name}.svg"
path.write_text(svg)
print(f" OK {name}.svg ({len(svg):,} bytes)")
async def capture_mode_screens() -> None:
"""Capture F1-F5 RF mode screens."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
modes = [
("f1", "spectrum", "Spectrum"),
("f2", "scan", "Scan"),
("f3", "monitor", "Monitor"),
("f4", "lband", "L-Band"),
("f5", "track", "Track"),
]
for key, filename, label in modes:
await pilot.press(key)
await pilot.pause(MODE_SWITCH_PAUSE)
svg = app.export_screenshot(title=f"SkyWalker-1 — {label}")
_save(svg, filename)
async def capture_device_screen() -> None:
"""Capture F6 Device screen — show EEPROM tab with hex dump."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Device screen
await pilot.press("f6")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for identity info to populate
await pilot.pause(1.0)
# Capture Firmware tab (default)
svg = app.export_screenshot(title="SkyWalker-1 — Device")
_save(svg, "device")
async def capture_stream_screen() -> None:
"""Capture F7 Stream screen — needs time for TS packets to accumulate."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Stream screen (auto-starts in demo mode)
await pilot.press("f7")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for PID stats and PSI tree to populate
await pilot.pause(2.0)
svg = app.export_screenshot(title="SkyWalker-1 — Stream")
_save(svg, "stream")
async def capture_config_screen() -> None:
"""Capture F8 Config screen."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Config screen
await pilot.press("f8")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for config status to load
await pilot.pause(0.5)
svg = app.export_screenshot(title="SkyWalker-1 — Config")
_save(svg, "config")
async def capture_motor_screen() -> None:
"""Capture F9 Motor screen — 3-column layout with signal gauge."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Motor screen
await pilot.press("f9")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for signal gauge to populate
await pilot.pause(1.0)
svg = app.export_screenshot(title="SkyWalker-1 — Motor Control")
_save(svg, "motor")
async def capture_survey_screen() -> None:
"""Capture F10 Survey screen — Full Band tab with spectrum plot."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Survey screen (Full Band tab is default)
await pilot.press("f10")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for demo spectrum data to render
await pilot.pause(1.5)
svg = app.export_screenshot(title="SkyWalker-1 — Carrier Survey")
_save(svg, "survey")
async def capture_dark_mode() -> None:
"""Capture dark-mode toggle with Star Wars notification toast."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Toggle to light then back to dark to trigger "Dark Side" notification
await pilot.press("d") # -> light
await pilot.pause(0.3)
await pilot.press("d") # -> dark (shows "Dark Side" toast)
await pilot.pause(NOTIFY_PAUSE)
svg = app.export_screenshot(title="SkyWalker-1 — Dark Side")
_save(svg, "dark-mode")
async def capture_splash() -> None:
"""Capture splash screen — needs show_splash=True and quick capture."""
app = _new_app(show_splash=True)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
# Splash auto-dismisses after 5s, capture it quickly
await pilot.pause(MOUNT_PAUSE)
svg = app.export_screenshot(title="SkyWalker-1 — Splash")
_save(svg, "splash")
async def capture_starwars() -> None:
"""Capture Star Wars easter egg — uses offline fallback crawl.
The offline crawl plays through several frames:
1. Black pause (2s)
2. "A long time ago..." (3s)
3. STAR WARS logo (4s)
4. Episode info (3s)
5. Opening crawl (per-line at 0.18s)
6. Star Destroyer (3.5s)
7. Credits (stays)
We wait long enough to capture the Star Destroyer frame.
"""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
await pilot.press("ctrl+w")
await pilot.pause(STARWARS_PAUSE)
svg = app.export_screenshot(title="SkyWalker-1 — Star Wars")
_save(svg, "starwars")
async def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"Generating TUI screenshots -> {OUTPUT_DIR}/\n")
captures = [
("Mode screens (F1-F5)", capture_mode_screens),
("Device screen (F6)", capture_device_screen),
("Stream screen (F7)", capture_stream_screen),
("Config screen (F8)", capture_config_screen),
("Motor screen (F9)", capture_motor_screen),
("Survey screen (F10)", capture_survey_screen),
("Dark mode toggle", capture_dark_mode),
("Splash screen", capture_splash),
("Star Wars easter egg", capture_starwars),
]
failed = []
for label, fn in captures:
print(f"── {label} ──")
try:
await fn()
except Exception as e:
print(f" FAIL {e}")
failed.append(label)
print()
count = len(list(OUTPUT_DIR.glob("*.svg")))
print(f"Done. {count} SVG screenshots generated.")
if failed:
print(f"\nFailed: {', '.join(failed)}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
#!/usr/bin/env python3
"""Generate SVG screenshots of every TUI screen for documentation.
Uses Textual's headless run_test() + Pilot API to programmatically navigate
each screen and export SVG renders. Requires no hardware runs entirely
with DemoDevice synthetic signal data.
Output: ../site/src/assets/tui/*.svg (13 screenshots)
Usage:
cd tui && uv run python scripts/generate_screenshots.py
"""
import asyncio
import sys
from pathlib import Path
# Ensure the src layout is importable when running from scripts/
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from skywalker_tui.app import SkyWalkerApp
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
OUTPUT_DIR = Path(__file__).resolve().parent.parent.parent / "site" / "src" / "assets" / "tui"
# Terminal size for screenshots — wide enough for sidebar + content
TERM_SIZE = (120, 36)
# Pause durations for rendering
MOUNT_PAUSE = 1.5 # initial mount + mode screen init
MODE_SWITCH_PAUSE = 0.8 # after F-key press
NOTIFY_PAUSE = 0.6 # for toast notifications
STARWARS_PAUSE = 12.0 # time for offline crawl to reach Star Destroyer frame
def _new_app(**kwargs) -> SkyWalkerApp:
"""Create a fresh app instance with demo device."""
return SkyWalkerApp(bridge=USBBridge(DemoDevice()), **kwargs)
def _save(svg: str, name: str) -> None:
path = OUTPUT_DIR / f"{name}.svg"
path.write_text(svg)
print(f" OK {name}.svg ({len(svg):,} bytes)")
async def capture_mode_screens() -> None:
"""Capture F1-F5 RF mode screens."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
modes = [
("f1", "spectrum", "Spectrum"),
("f2", "scan", "Scan"),
("f3", "monitor", "Monitor"),
("f4", "lband", "L-Band"),
("f5", "track", "Track"),
]
for key, filename, label in modes:
await pilot.press(key)
await pilot.pause(MODE_SWITCH_PAUSE)
svg = app.export_screenshot(title=f"SkyWalker-1 — {label}")
_save(svg, filename)
async def capture_device_screen() -> None:
"""Capture F6 Device screen — show EEPROM tab with hex dump."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Device screen
await pilot.press("f6")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for identity info to populate
await pilot.pause(1.0)
# Capture Firmware tab (default)
svg = app.export_screenshot(title="SkyWalker-1 — Device")
_save(svg, "device")
async def capture_stream_screen() -> None:
"""Capture F7 Stream screen — needs time for TS packets to accumulate."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Stream screen (auto-starts in demo mode)
await pilot.press("f7")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for PID stats and PSI tree to populate
await pilot.pause(2.0)
svg = app.export_screenshot(title="SkyWalker-1 — Stream")
_save(svg, "stream")
async def capture_config_screen() -> None:
"""Capture F8 Config screen."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Config screen
await pilot.press("f8")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for config status to load
await pilot.pause(0.5)
svg = app.export_screenshot(title="SkyWalker-1 — Config")
_save(svg, "config")
async def capture_motor_screen() -> None:
"""Capture F9 Motor screen — 3-column layout with signal gauge."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Motor screen
await pilot.press("f9")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for signal gauge to populate
await pilot.pause(1.0)
svg = app.export_screenshot(title="SkyWalker-1 — Motor Control")
_save(svg, "motor")
async def capture_survey_screen() -> None:
"""Capture F10 Survey screen — Full Band tab with spectrum plot."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Switch to Survey screen (Full Band tab is default)
await pilot.press("f10")
await pilot.pause(MODE_SWITCH_PAUSE)
# Wait for demo spectrum data to render
await pilot.pause(1.5)
svg = app.export_screenshot(title="SkyWalker-1 — Carrier Survey")
_save(svg, "survey")
async def capture_dark_mode() -> None:
"""Capture dark-mode toggle with Star Wars notification toast."""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
# Toggle to light then back to dark to trigger "Dark Side" notification
await pilot.press("d") # -> light
await pilot.pause(0.3)
await pilot.press("d") # -> dark (shows "Dark Side" toast)
await pilot.pause(NOTIFY_PAUSE)
svg = app.export_screenshot(title="SkyWalker-1 — Dark Side")
_save(svg, "dark-mode")
async def capture_splash() -> None:
"""Capture splash screen — needs show_splash=True and quick capture."""
app = _new_app(show_splash=True)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
# Splash auto-dismisses after 5s, capture it quickly
await pilot.pause(MOUNT_PAUSE)
svg = app.export_screenshot(title="SkyWalker-1 — Splash")
_save(svg, "splash")
async def capture_starwars() -> None:
"""Capture Star Wars easter egg — uses offline fallback crawl.
The offline crawl plays through several frames:
1. Black pause (2s)
2. "A long time ago..." (3s)
3. STAR WARS logo (4s)
4. Episode info (3s)
5. Opening crawl (per-line at 0.18s)
6. Star Destroyer (3.5s)
7. Credits (stays)
We wait long enough to capture the Star Destroyer frame.
"""
app = _new_app(show_splash=False)
async with app.run_test(size=TERM_SIZE, headless=True) as pilot:
await pilot.pause(MOUNT_PAUSE)
await pilot.press("ctrl+w")
await pilot.pause(STARWARS_PAUSE)
svg = app.export_screenshot(title="SkyWalker-1 — Star Wars")
_save(svg, "starwars")
async def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"Generating TUI screenshots -> {OUTPUT_DIR}/\n")
captures = [
("Mode screens (F1-F5)", capture_mode_screens),
("Device screen (F6)", capture_device_screen),
("Stream screen (F7)", capture_stream_screen),
("Config screen (F8)", capture_config_screen),
("Motor screen (F9)", capture_motor_screen),
("Survey screen (F10)", capture_survey_screen),
("Dark mode toggle", capture_dark_mode),
("Splash screen", capture_splash),
("Star Wars easter egg", capture_starwars),
]
failed = []
for label, fn in captures:
print(f"── {label} ──")
try:
await fn()
except Exception as e:
print(f" FAIL {e}")
failed.append(label)
print()
count = len(list(OUTPUT_DIR.glob("*.svg")))
print(f"Done. {count} SVG screenshots generated.")
if failed:
print(f"\nFailed: {', '.join(failed)}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,124 +1,124 @@
#!/usr/bin/env python3
"""Pre-render splash art as ANSI half-block text for instant display.
Converts source images (PNG/JPG/GIF) into .ans files using Unicode half-block
characters with 24-bit ANSI color. This eliminates the runtime Pillow decode
and textual-image protocol detection, making splash display instant.
Each terminal cell renders two vertical pixels using the upper-half-block
character (U+2580 '') with fg=top_pixel, bg=bottom_pixel for 2x vertical
resolution.
Usage:
python scripts/prebake_splash.py [--width 80]
"""
import argparse
from pathlib import Path
from PIL import Image
ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash"
# Source images to pre-bake
SOURCE_FILES = [
"seti-satellite.png",
"dialtone.jpg",
"so-far-away.gif",
"prodigy-out-of-space.jpg",
"space-docker.gif",
]
def image_to_ansi(img_path: Path, width: int = 80) -> str:
"""Convert an image to ANSI half-block art.
Uses (upper half block) with fg=top pixel, bg=bottom pixel to get
2x vertical resolution. Emits 24-bit ANSI color escape sequences,
optimized to only change fg/bg when the color actually changes.
"""
img = Image.open(img_path)
# Use first frame for animated GIFs
if hasattr(img, "n_frames") and img.n_frames > 1:
img.seek(0)
img = img.convert("RGBA")
# Resize maintaining aspect ratio, height rounded to even for half-blocks
ratio = img.height / img.width
pixel_height = int(width * ratio)
pixel_height += pixel_height % 2 # ensure even
img = img.resize((width, pixel_height), Image.LANCZOS)
lines = []
for y in range(0, pixel_height, 2):
chunks = []
prev_fg = None
prev_bg = None
for x in range(width):
tr, tg, tb, ta = img.getpixel((x, y))
if y + 1 < pixel_height:
br, bg, bb, ba = img.getpixel((x, y + 1))
else:
br, bg, bb, ba = 0, 0, 0, 0
# Treat near-transparent pixels as black
if ta < 128:
tr, tg, tb = 0, 0, 0
if ba < 128:
br, bg, bb = 0, 0, 0
fg = (tr, tg, tb)
bk = (br, bg, bb)
# Only emit escape codes when color changes
codes = []
if fg != prev_fg:
codes.append(f"\033[38;2;{fg[0]};{fg[1]};{fg[2]}m")
prev_fg = fg
if bk != prev_bg:
codes.append(f"\033[48;2;{bk[0]};{bk[1]};{bk[2]}m")
prev_bg = bk
chunks.append("".join(codes) + "\u2580")
lines.append("".join(chunks) + "\033[0m")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Pre-bake splash art as ANSI half-block text")
parser.add_argument("--width", type=int, default=80, help="Output width in columns (default: 80)")
args = parser.parse_args()
if not ASSETS_DIR.exists():
print(f"Assets directory not found: {ASSETS_DIR}")
return
for filename in SOURCE_FILES:
src = ASSETS_DIR / filename
if not src.exists():
print(f" SKIP {filename} (not found)")
continue
stem = src.stem
dst = ASSETS_DIR / f"{stem}.ans"
print(f" BAKE {filename} -> {stem}.ans ({args.width} cols) ...", end=" ", flush=True)
ansi_text = image_to_ansi(src, width=args.width)
dst.write_text(ansi_text)
# Stats
src_kb = src.stat().st_size / 1024
dst_kb = dst.stat().st_size / 1024
line_count = ansi_text.count("\n") + 1
print(f"{src_kb:.1f}KB -> {dst_kb:.1f}KB ({line_count} lines)")
print("\nDone. Pre-baked .ans files are ready for instant splash display.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Pre-render splash art as ANSI half-block text for instant display.
Converts source images (PNG/JPG/GIF) into .ans files using Unicode half-block
characters with 24-bit ANSI color. This eliminates the runtime Pillow decode
and textual-image protocol detection, making splash display instant.
Each terminal cell renders two vertical pixels using the upper-half-block
character (U+2580 '') with fg=top_pixel, bg=bottom_pixel for 2x vertical
resolution.
Usage:
python scripts/prebake_splash.py [--width 80]
"""
import argparse
from pathlib import Path
from PIL import Image
ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash"
# Source images to pre-bake
SOURCE_FILES = [
"seti-satellite.png",
"dialtone.jpg",
"so-far-away.gif",
"prodigy-out-of-space.jpg",
"space-docker.gif",
]
def image_to_ansi(img_path: Path, width: int = 80) -> str:
"""Convert an image to ANSI half-block art.
Uses (upper half block) with fg=top pixel, bg=bottom pixel to get
2x vertical resolution. Emits 24-bit ANSI color escape sequences,
optimized to only change fg/bg when the color actually changes.
"""
img = Image.open(img_path)
# Use first frame for animated GIFs
if hasattr(img, "n_frames") and img.n_frames > 1:
img.seek(0)
img = img.convert("RGBA")
# Resize maintaining aspect ratio, height rounded to even for half-blocks
ratio = img.height / img.width
pixel_height = int(width * ratio)
pixel_height += pixel_height % 2 # ensure even
img = img.resize((width, pixel_height), Image.LANCZOS)
lines = []
for y in range(0, pixel_height, 2):
chunks = []
prev_fg = None
prev_bg = None
for x in range(width):
tr, tg, tb, ta = img.getpixel((x, y))
if y + 1 < pixel_height:
br, bg, bb, ba = img.getpixel((x, y + 1))
else:
br, bg, bb, ba = 0, 0, 0, 0
# Treat near-transparent pixels as black
if ta < 128:
tr, tg, tb = 0, 0, 0
if ba < 128:
br, bg, bb = 0, 0, 0
fg = (tr, tg, tb)
bk = (br, bg, bb)
# Only emit escape codes when color changes
codes = []
if fg != prev_fg:
codes.append(f"\033[38;2;{fg[0]};{fg[1]};{fg[2]}m")
prev_fg = fg
if bk != prev_bg:
codes.append(f"\033[48;2;{bk[0]};{bk[1]};{bk[2]}m")
prev_bg = bk
chunks.append("".join(codes) + "\u2580")
lines.append("".join(chunks) + "\033[0m")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Pre-bake splash art as ANSI half-block text")
parser.add_argument("--width", type=int, default=80, help="Output width in columns (default: 80)")
args = parser.parse_args()
if not ASSETS_DIR.exists():
print(f"Assets directory not found: {ASSETS_DIR}")
return
for filename in SOURCE_FILES:
src = ASSETS_DIR / filename
if not src.exists():
print(f" SKIP {filename} (not found)")
continue
stem = src.stem
dst = ASSETS_DIR / f"{stem}.ans"
print(f" BAKE {filename} -> {stem}.ans ({args.width} cols) ...", end=" ", flush=True)
ansi_text = image_to_ansi(src, width=args.width)
dst.write_text(ansi_text)
# Stats
src_kb = src.stat().st_size / 1024
dst_kb = dst.stat().st_size / 1024
line_count = ansi_text.count("\n") + 1
print(f"{src_kb:.1f}KB -> {dst_kb:.1f}KB ({line_count} lines)")
print("\nDone. Pre-baked .ans files are ready for instant splash display.")
if __name__ == "__main__":
main()

View file

@ -1,12 +1,12 @@
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
import sys
from pathlib import Path
__version__ = "0.1.0"
# Ensure skywalker_lib (in sibling tools/ directory) is importable.
# Consolidated here so app.py and status_bar.py don't duplicate this.
_tools_dir = Path(__file__).resolve().parent.parent.parent.parent / "tools"
if _tools_dir.is_dir() and str(_tools_dir) not in sys.path:
sys.path.insert(0, str(_tools_dir))
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
import sys
from pathlib import Path
__version__ = "0.1.0"
# Ensure skywalker_lib (in sibling tools/ directory) is importable.
# Consolidated here so app.py and status_bar.py don't duplicate this.
_tools_dir = Path(__file__).resolve().parent.parent.parent.parent / "tools"
if _tools_dir.is_dir() and str(_tools_dir) not in sys.path:
sys.path.insert(0, str(_tools_dir))

View file

@ -1,222 +1,222 @@
"""SkyWalker-1 TUI — main application.
Provides mode switching between 8 operating modes via a sidebar and F-key
shortcuts. Each mode is a Container subclass that manages its own workers.
Note: We use "rf_mode" terminology for our operating modes to avoid colliding
with Textual's built-in App.mode / _current_mode / _screen_stacks system.
"""
import argparse
import sys
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Button, Label, Static, ContentSwitcher
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
from skywalker_tui.widgets.status_bar import DeviceStatusBar
from skywalker_tui.screens.spectrum import SpectrumScreen
from skywalker_tui.screens.scan import ScanScreen
from skywalker_tui.screens.monitor import MonitorScreen
from skywalker_tui.screens.lband import LBandScreen
from skywalker_tui.screens.track import TrackScreen
from skywalker_tui.screens.device import DeviceScreen
from skywalker_tui.screens.stream import StreamScreen
from skywalker_tui.screens.config import ConfigScreen
from skywalker_tui.screens.motor import MotorScreen
from skywalker_tui.screens.survey import SurveyScreen
MODES = {
"spectrum": ("F1 Spectrum", SpectrumScreen),
"scan": ("F2 Scan", ScanScreen),
"monitor": ("F3 Monitor", MonitorScreen),
"lband": ("F4 L-Band", LBandScreen),
"track": ("F5 Track", TrackScreen),
"device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
}
class SkyWalkerApp(App):
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
TITLE = "SkyWalker-1"
SUB_TITLE = "DVB-S RF Tool"
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "rf_mode('spectrum')", "Spectrum", show=True),
Binding("f2", "rf_mode('scan')", "Scan", show=True),
Binding("f3", "rf_mode('monitor')", "Monitor", show=True),
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
Binding("f5", "rf_mode('track')", "Track", show=True),
Binding("f6", "rf_mode('device')", "Device", show=True),
Binding("f7", "rf_mode('stream')", "Stream", show=True),
Binding("f8", "rf_mode('config')", "Config", show=True),
Binding("f9", "rf_mode('motor')", "Motor", show=True),
Binding("f10", "rf_mode('survey')", "Survey", show=True),
Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", show=True),
Binding("ctrl+w", "starwars", "Star Wars", show=False),
]
def __init__(self, bridge: USBBridge, initial_mode: str = "spectrum",
show_splash: bool = True):
super().__init__()
self._bridge = bridge
self._initial_rf_mode = initial_mode
self._active_rf_mode = initial_mode
self._rf_screens: dict[str, object] = {}
self._show_splash = show_splash
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield Label("[bold #00d4aa]SkyWalker-1[/]", classes="sidebar-heading")
yield Label("[#506878]DVB-S RF Tool[/]", classes="sidebar-heading")
yield Static("")
for mode_key, (label, _cls) in MODES.items():
yield Button(label, id=f"btn-{mode_key}", classes="mode-button")
yield Static("")
yield DeviceStatusBar(self._bridge)
yield ContentSwitcher(id="content-area")
yield Footer()
def on_mount(self) -> None:
# Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge)
if self._show_splash:
# Push splash FIRST, then init mode screens behind it.
# Two-tick chain: tick 1 = splash renders, tick 2 = heavy work.
self.call_later(self._push_splash)
else:
self.call_later(self._init_mode_screens)
def _push_splash(self) -> None:
"""Push splash screen, then defer heavy mode screen init."""
from skywalker_tui.screens.splash import SplashScreen
try:
self.push_screen(SplashScreen())
except Exception:
pass
# Mode screens mount behind the splash overlay — pre-baked ANSI art
# renders instantly so no delay needed before heavy work starts
self.call_later(self._init_mode_screens)
def _init_mode_screens(self) -> None:
"""Mount all 8 mode screens into the content switcher."""
switcher = self.query_one("#content-area", ContentSwitcher)
for mode_key, (_label, cls) in MODES.items():
screen = cls(self._bridge, id=f"screen-{mode_key}")
self._rf_screens[mode_key] = screen
switcher.mount(screen)
self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None:
"""Switch to a different RF operating mode."""
if mode not in MODES:
return
self._active_rf_mode = mode
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = f"screen-{mode}"
# Update sidebar button highlights
for mode_key in MODES:
btn = self.query_one(f"#btn-{mode_key}", Button)
btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_class("-active")
self.sub_title = f"DVB-S RF Tool \u2014 {MODES[mode][0]}"
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle sidebar mode button clicks."""
btn_id = event.button.id or ""
if btn_id.startswith("btn-"):
mode = btn_id[4:]
if mode in MODES:
self.action_rf_mode(mode)
def action_toggle_dark(self) -> None:
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
if self.current_theme.dark:
self.notify(
"Welcome to the Dark Side.",
title="The Force is strong with this one",
severity="warning",
timeout=4,
)
else:
self.notify(
"The Force awakens.",
title="A New Hope",
severity="information",
timeout=4,
)
def action_starwars(self) -> None:
"""Easter egg: stream ASCII Star Wars from telnet."""
from skywalker_tui.screens.starwars import StarWarsScreen
self.push_screen(StarWarsScreen())
def main():
parser = argparse.ArgumentParser(
prog="skywalker-tui",
description="Textual TUI for Genpix SkyWalker-1 DVB-S receiver",
)
parser.add_argument(
"--demo", action="store_true",
help="Use synthetic signal data (no hardware required)",
)
parser.add_argument(
"--no-splash", action="store_true",
help="Skip the splash screen on startup",
)
parser.add_argument(
"mode", nargs="?", default="spectrum",
choices=list(MODES.keys()),
help="Initial mode (default: spectrum)",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="Verbose USB logging (hardware mode only)",
)
args = parser.parse_args()
if args.demo:
device = DemoDevice()
bridge = USBBridge(device)
else:
try:
from skywalker_lib import SkyWalker1
device = SkyWalker1(verbose=args.verbose)
device.open()
bridge = USBBridge(device)
except Exception as e:
print(f"Cannot open SkyWalker-1: {e}", file=sys.stderr)
print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1)
app = SkyWalkerApp(
bridge=bridge,
initial_mode=args.mode,
show_splash=not args.no_splash,
)
try:
app.run()
finally:
bridge.close()
"""SkyWalker-1 TUI — main application.
Provides mode switching between 8 operating modes via a sidebar and F-key
shortcuts. Each mode is a Container subclass that manages its own workers.
Note: We use "rf_mode" terminology for our operating modes to avoid colliding
with Textual's built-in App.mode / _current_mode / _screen_stacks system.
"""
import argparse
import sys
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.widgets import Header, Footer, Button, Label, Static, ContentSwitcher
from skywalker_tui.bridge import USBBridge
from skywalker_tui.demo import DemoDevice
from skywalker_tui.widgets.status_bar import DeviceStatusBar
from skywalker_tui.screens.spectrum import SpectrumScreen
from skywalker_tui.screens.scan import ScanScreen
from skywalker_tui.screens.monitor import MonitorScreen
from skywalker_tui.screens.lband import LBandScreen
from skywalker_tui.screens.track import TrackScreen
from skywalker_tui.screens.device import DeviceScreen
from skywalker_tui.screens.stream import StreamScreen
from skywalker_tui.screens.config import ConfigScreen
from skywalker_tui.screens.motor import MotorScreen
from skywalker_tui.screens.survey import SurveyScreen
MODES = {
"spectrum": ("F1 Spectrum", SpectrumScreen),
"scan": ("F2 Scan", ScanScreen),
"monitor": ("F3 Monitor", MonitorScreen),
"lband": ("F4 L-Band", LBandScreen),
"track": ("F5 Track", TrackScreen),
"device": ("F6 Device", DeviceScreen),
"stream": ("F7 Stream", StreamScreen),
"config": ("F8 Config", ConfigScreen),
"motor": ("F9 Motor", MotorScreen),
"survey": ("F10 Survey", SurveyScreen),
}
class SkyWalkerApp(App):
"""Textual TUI for Genpix SkyWalker-1 DVB-S receiver."""
TITLE = "SkyWalker-1"
SUB_TITLE = "DVB-S RF Tool"
CSS_PATH = "theme.tcss"
BINDINGS = [
Binding("f1", "rf_mode('spectrum')", "Spectrum", show=True),
Binding("f2", "rf_mode('scan')", "Scan", show=True),
Binding("f3", "rf_mode('monitor')", "Monitor", show=True),
Binding("f4", "rf_mode('lband')", "L-Band", show=True),
Binding("f5", "rf_mode('track')", "Track", show=True),
Binding("f6", "rf_mode('device')", "Device", show=True),
Binding("f7", "rf_mode('stream')", "Stream", show=True),
Binding("f8", "rf_mode('config')", "Config", show=True),
Binding("f9", "rf_mode('motor')", "Motor", show=True),
Binding("f10", "rf_mode('survey')", "Survey", show=True),
Binding("q", "quit", "Quit", show=True),
Binding("d", "toggle_dark", "Theme", show=True),
Binding("ctrl+w", "starwars", "Star Wars", show=False),
]
def __init__(self, bridge: USBBridge, initial_mode: str = "spectrum",
show_splash: bool = True):
super().__init__()
self._bridge = bridge
self._initial_rf_mode = initial_mode
self._active_rf_mode = initial_mode
self._rf_screens: dict[str, object] = {}
self._show_splash = show_splash
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield Label("[bold #00d4aa]SkyWalker-1[/]", classes="sidebar-heading")
yield Label("[#506878]DVB-S RF Tool[/]", classes="sidebar-heading")
yield Static("")
for mode_key, (label, _cls) in MODES.items():
yield Button(label, id=f"btn-{mode_key}", classes="mode-button")
yield Static("")
yield DeviceStatusBar(self._bridge)
yield ContentSwitcher(id="content-area")
yield Footer()
def on_mount(self) -> None:
# Initialize status bar (lightweight)
status = self.query_one(DeviceStatusBar)
status.update_status(self._bridge)
if self._show_splash:
# Push splash FIRST, then init mode screens behind it.
# Two-tick chain: tick 1 = splash renders, tick 2 = heavy work.
self.call_later(self._push_splash)
else:
self.call_later(self._init_mode_screens)
def _push_splash(self) -> None:
"""Push splash screen, then defer heavy mode screen init."""
from skywalker_tui.screens.splash import SplashScreen
try:
self.push_screen(SplashScreen())
except Exception:
pass
# Mode screens mount behind the splash overlay — pre-baked ANSI art
# renders instantly so no delay needed before heavy work starts
self.call_later(self._init_mode_screens)
def _init_mode_screens(self) -> None:
"""Mount all 8 mode screens into the content switcher."""
switcher = self.query_one("#content-area", ContentSwitcher)
for mode_key, (_label, cls) in MODES.items():
screen = cls(self._bridge, id=f"screen-{mode_key}")
self._rf_screens[mode_key] = screen
switcher.mount(screen)
self.action_rf_mode(self._initial_rf_mode)
def action_rf_mode(self, mode: str) -> None:
"""Switch to a different RF operating mode."""
if mode not in MODES:
return
self._active_rf_mode = mode
switcher = self.query_one("#content-area", ContentSwitcher)
switcher.current = f"screen-{mode}"
# Update sidebar button highlights
for mode_key in MODES:
btn = self.query_one(f"#btn-{mode_key}", Button)
btn.remove_class("-active")
self.query_one(f"#btn-{mode}", Button).add_class("-active")
self.sub_title = f"DVB-S RF Tool \u2014 {MODES[mode][0]}"
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle sidebar mode button clicks."""
btn_id = event.button.id or ""
if btn_id.startswith("btn-"):
mode = btn_id[4:]
if mode in MODES:
self.action_rf_mode(mode)
def action_toggle_dark(self) -> None:
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
if self.current_theme.dark:
self.notify(
"Welcome to the Dark Side.",
title="The Force is strong with this one",
severity="warning",
timeout=4,
)
else:
self.notify(
"The Force awakens.",
title="A New Hope",
severity="information",
timeout=4,
)
def action_starwars(self) -> None:
"""Easter egg: stream ASCII Star Wars from telnet."""
from skywalker_tui.screens.starwars import StarWarsScreen
self.push_screen(StarWarsScreen())
def main():
parser = argparse.ArgumentParser(
prog="skywalker-tui",
description="Textual TUI for Genpix SkyWalker-1 DVB-S receiver",
)
parser.add_argument(
"--demo", action="store_true",
help="Use synthetic signal data (no hardware required)",
)
parser.add_argument(
"--no-splash", action="store_true",
help="Skip the splash screen on startup",
)
parser.add_argument(
"mode", nargs="?", default="spectrum",
choices=list(MODES.keys()),
help="Initial mode (default: spectrum)",
)
parser.add_argument(
"-v", "--verbose", action="store_true",
help="Verbose USB logging (hardware mode only)",
)
args = parser.parse_args()
if args.demo:
device = DemoDevice()
bridge = USBBridge(device)
else:
try:
from skywalker_lib import SkyWalker1
device = SkyWalker1(verbose=args.verbose)
device.open()
bridge = USBBridge(device)
except Exception as e:
print(f"Cannot open SkyWalker-1: {e}", file=sys.stderr)
print("Use --demo for synthetic signal data.", file=sys.stderr)
sys.exit(1)
app = SkyWalkerApp(
bridge=bridge,
initial_mode=args.mode,
show_splash=not args.no_splash,
)
try:
app.run()
finally:
bridge.close()

View file

@ -1,254 +1,254 @@
"""Thread-safe bridge between Textual async event loop and blocking pyusb calls.
All SkyWalker1 (or DemoDevice) methods are blocking I/O they perform USB control
transfers that can take 2-200ms each. Textual's event loop is asyncio-based, so
calling these directly would freeze the UI.
The USBBridge wraps every device method behind a threading.Lock to prevent concurrent
USB access (the BCM4500 can't handle overlapping control transfers) and exposes them
as plain synchronous methods meant to be called from Textual @work(thread=True) workers.
"""
import threading
class USBBridge:
"""Thread-safe wrapper around SkyWalker1 or DemoDevice."""
def __init__(self, device):
self._dev = device
self._lock = threading.RLock()
@property
def is_demo(self) -> bool:
return hasattr(self._dev, "_demo")
def open(self):
with self._lock:
if hasattr(self._dev, "open"):
self._dev.open()
def close(self):
with self._lock:
if hasattr(self._dev, "close"):
self._dev.close()
def get_fw_version(self) -> dict:
with self._lock:
return self._dev.get_fw_version()
def get_config(self) -> int:
with self._lock:
return self._dev.get_config()
def ensure_booted(self):
with self._lock:
self._dev.ensure_booted()
def signal_monitor(self) -> dict:
with self._lock:
return self._dev.signal_monitor()
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
step_mhz: float = 5.0, dwell_ms: int = 10,
sr_ksps: int = 20000, mod_index: int = 0,
fec_index: int = 5, callback=None) -> tuple:
with self._lock:
return self._dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz, dwell_ms,
sr_ksps, mod_index, fec_index, callback,
)
def tune_monitor(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int,
dwell_ms: int = 10) -> dict:
with self._lock:
return self._dev.tune_monitor(
symbol_rate_sps, freq_khz, mod_index, fec_index, dwell_ms,
)
def tune(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int):
with self._lock:
self._dev.tune(symbol_rate_sps, freq_khz, mod_index, fec_index)
def set_lnb_voltage(self, high: bool):
with self._lock:
self._dev.set_lnb_voltage(high)
def set_22khz_tone(self, on: bool):
with self._lock:
self._dev.set_22khz_tone(on)
def configure_lnb(self, pol=None, band=None, lnb_lo=None,
disable_lnb=False) -> float:
with self._lock:
return self._dev.configure_lnb(pol, band, lnb_lo, disable_lnb)
def blind_scan(self, freq_khz: int, sr_min: int, sr_max: int,
sr_step: int) -> dict | None:
"""Run blind scan at a single frequency. Returns result dict or None."""
with self._lock:
if hasattr(self._dev, "blind_scan"):
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
return None
# -- Device info (extended) --
def get_serial_number(self) -> bytes:
with self._lock:
return self._dev.get_serial_number()
def get_usb_speed(self) -> int:
with self._lock:
return self._dev.get_usb_speed()
def get_vendor_string(self) -> str:
with self._lock:
return self._dev.get_vendor_string()
def get_product_string(self) -> str:
with self._lock:
return self._dev.get_product_string()
# -- FX2 RAM --
def fx2_ram_read(self, addr: int, length: int) -> bytes:
with self._lock:
return self._dev.fx2_ram_read(addr, length)
def fx2_ram_write(self, addr: int, data: bytes) -> int:
with self._lock:
return self._dev.fx2_ram_write(addr, data)
def fx2_cpu_halt(self) -> None:
with self._lock:
self._dev.fx2_cpu_halt()
def fx2_cpu_start(self) -> None:
with self._lock:
self._dev.fx2_cpu_start()
# -- EEPROM --
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
with self._lock:
return self._dev.eeprom_read(offset, length)
def eeprom_write_page(self, offset: int, data: bytes) -> int:
with self._lock:
return self._dev.eeprom_write_page(offset, data)
def eeprom_read_all(self, size: int = 16384) -> bytes:
with self._lock:
return self._dev.eeprom_read_all(size)
# -- Diagnostics --
def boot_debug(self, mode: int) -> dict:
with self._lock:
return self._dev.boot_debug(mode)
def i2c_bus_scan(self) -> list[int]:
with self._lock:
return self._dev.i2c_bus_scan()
def i2c_raw_read(self, slave: int, reg: int) -> int:
with self._lock:
return self._dev.i2c_raw_read(slave, reg)
# -- Streaming --
def arm_transfer(self, on: bool) -> None:
with self._lock:
self._dev.arm_transfer(on)
def read_stream(self, size: int = 8192, timeout: int = 1000) -> bytes:
with self._lock:
return self._dev.read_stream(size, timeout)
# -- Config --
def send_diseqc_message(self, msg: bytes) -> None:
with self._lock:
self._dev.send_diseqc_message(msg)
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
with self._lock:
self._dev.send_diseqc_tone_burst(mini_cmd)
def start_intersil(self, on: bool = True) -> int:
with self._lock:
return self._dev.start_intersil(on)
def set_extra_voltage(self, on: bool) -> None:
with self._lock:
self._dev.set_extra_voltage(on)
def boot(self, on: bool = True) -> int:
with self._lock:
return self._dev.boot(on)
def get_signal_lock(self) -> bool:
with self._lock:
return self._dev.get_signal_lock()
def get_signal_strength(self) -> dict:
with self._lock:
return self._dev.get_signal_strength()
def multi_reg_read(self, start_reg: int, count: int) -> bytes:
with self._lock:
return self._dev.multi_reg_read(start_reg, count)
# -- Motor control (v3.03+) --
def motor_halt(self) -> None:
with self._lock:
self._dev.motor_halt()
def motor_drive_east(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_east(steps)
def motor_drive_west(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_west(steps)
def motor_store_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_store_position(slot)
def motor_goto_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_goto_position(slot)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
with self._lock:
self._dev.motor_goto_x(observer_lon, sat_lon)
def motor_set_limit(self, direction: str) -> None:
with self._lock:
self._dev.motor_set_limit(direction)
def motor_disable_limits(self) -> None:
with self._lock:
self._dev.motor_disable_limits()
def get_last_error(self) -> int:
with self._lock:
return self._dev.get_last_error()
def get_last_error_str(self) -> str:
with self._lock:
return self._dev.get_last_error_str()
def get_stream_diag(self, reset: bool = False) -> dict:
with self._lock:
return self._dev.get_stream_diag(reset=reset)
def get_hotplug_status(self, reset: bool = False,
force_scan: bool = False) -> dict:
with self._lock:
return self._dev.get_hotplug_status(reset=reset,
force_scan=force_scan)
"""Thread-safe bridge between Textual async event loop and blocking pyusb calls.
All SkyWalker1 (or DemoDevice) methods are blocking I/O they perform USB control
transfers that can take 2-200ms each. Textual's event loop is asyncio-based, so
calling these directly would freeze the UI.
The USBBridge wraps every device method behind a threading.Lock to prevent concurrent
USB access (the BCM4500 can't handle overlapping control transfers) and exposes them
as plain synchronous methods meant to be called from Textual @work(thread=True) workers.
"""
import threading
class USBBridge:
"""Thread-safe wrapper around SkyWalker1 or DemoDevice."""
def __init__(self, device):
self._dev = device
self._lock = threading.RLock()
@property
def is_demo(self) -> bool:
return hasattr(self._dev, "_demo")
def open(self):
with self._lock:
if hasattr(self._dev, "open"):
self._dev.open()
def close(self):
with self._lock:
if hasattr(self._dev, "close"):
self._dev.close()
def get_fw_version(self) -> dict:
with self._lock:
return self._dev.get_fw_version()
def get_config(self) -> int:
with self._lock:
return self._dev.get_config()
def ensure_booted(self):
with self._lock:
self._dev.ensure_booted()
def signal_monitor(self) -> dict:
with self._lock:
return self._dev.signal_monitor()
def sweep_spectrum(self, start_mhz: float, stop_mhz: float,
step_mhz: float = 5.0, dwell_ms: int = 10,
sr_ksps: int = 20000, mod_index: int = 0,
fec_index: int = 5, callback=None) -> tuple:
with self._lock:
return self._dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz, dwell_ms,
sr_ksps, mod_index, fec_index, callback,
)
def tune_monitor(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int,
dwell_ms: int = 10) -> dict:
with self._lock:
return self._dev.tune_monitor(
symbol_rate_sps, freq_khz, mod_index, fec_index, dwell_ms,
)
def tune(self, symbol_rate_sps: int, freq_khz: int,
mod_index: int, fec_index: int):
with self._lock:
self._dev.tune(symbol_rate_sps, freq_khz, mod_index, fec_index)
def set_lnb_voltage(self, high: bool):
with self._lock:
self._dev.set_lnb_voltage(high)
def set_22khz_tone(self, on: bool):
with self._lock:
self._dev.set_22khz_tone(on)
def configure_lnb(self, pol=None, band=None, lnb_lo=None,
disable_lnb=False) -> float:
with self._lock:
return self._dev.configure_lnb(pol, band, lnb_lo, disable_lnb)
def blind_scan(self, freq_khz: int, sr_min: int, sr_max: int,
sr_step: int) -> dict | None:
"""Run blind scan at a single frequency. Returns result dict or None."""
with self._lock:
if hasattr(self._dev, "blind_scan"):
return self._dev.blind_scan(freq_khz, sr_min, sr_max, sr_step)
return None
# -- Device info (extended) --
def get_serial_number(self) -> bytes:
with self._lock:
return self._dev.get_serial_number()
def get_usb_speed(self) -> int:
with self._lock:
return self._dev.get_usb_speed()
def get_vendor_string(self) -> str:
with self._lock:
return self._dev.get_vendor_string()
def get_product_string(self) -> str:
with self._lock:
return self._dev.get_product_string()
# -- FX2 RAM --
def fx2_ram_read(self, addr: int, length: int) -> bytes:
with self._lock:
return self._dev.fx2_ram_read(addr, length)
def fx2_ram_write(self, addr: int, data: bytes) -> int:
with self._lock:
return self._dev.fx2_ram_write(addr, data)
def fx2_cpu_halt(self) -> None:
with self._lock:
self._dev.fx2_cpu_halt()
def fx2_cpu_start(self) -> None:
with self._lock:
self._dev.fx2_cpu_start()
# -- EEPROM --
def eeprom_read(self, offset: int, length: int = 64) -> bytes:
with self._lock:
return self._dev.eeprom_read(offset, length)
def eeprom_write_page(self, offset: int, data: bytes) -> int:
with self._lock:
return self._dev.eeprom_write_page(offset, data)
def eeprom_read_all(self, size: int = 16384) -> bytes:
with self._lock:
return self._dev.eeprom_read_all(size)
# -- Diagnostics --
def boot_debug(self, mode: int) -> dict:
with self._lock:
return self._dev.boot_debug(mode)
def i2c_bus_scan(self) -> list[int]:
with self._lock:
return self._dev.i2c_bus_scan()
def i2c_raw_read(self, slave: int, reg: int) -> int:
with self._lock:
return self._dev.i2c_raw_read(slave, reg)
# -- Streaming --
def arm_transfer(self, on: bool) -> None:
with self._lock:
self._dev.arm_transfer(on)
def read_stream(self, size: int = 8192, timeout: int = 1000) -> bytes:
with self._lock:
return self._dev.read_stream(size, timeout)
# -- Config --
def send_diseqc_message(self, msg: bytes) -> None:
with self._lock:
self._dev.send_diseqc_message(msg)
def send_diseqc_tone_burst(self, mini_cmd: int) -> None:
with self._lock:
self._dev.send_diseqc_tone_burst(mini_cmd)
def start_intersil(self, on: bool = True) -> int:
with self._lock:
return self._dev.start_intersil(on)
def set_extra_voltage(self, on: bool) -> None:
with self._lock:
self._dev.set_extra_voltage(on)
def boot(self, on: bool = True) -> int:
with self._lock:
return self._dev.boot(on)
def get_signal_lock(self) -> bool:
with self._lock:
return self._dev.get_signal_lock()
def get_signal_strength(self) -> dict:
with self._lock:
return self._dev.get_signal_strength()
def multi_reg_read(self, start_reg: int, count: int) -> bytes:
with self._lock:
return self._dev.multi_reg_read(start_reg, count)
# -- Motor control (v3.03+) --
def motor_halt(self) -> None:
with self._lock:
self._dev.motor_halt()
def motor_drive_east(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_east(steps)
def motor_drive_west(self, steps: int = 0) -> None:
with self._lock:
self._dev.motor_drive_west(steps)
def motor_store_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_store_position(slot)
def motor_goto_position(self, slot: int) -> None:
with self._lock:
self._dev.motor_goto_position(slot)
def motor_goto_x(self, observer_lon: float, sat_lon: float) -> None:
with self._lock:
self._dev.motor_goto_x(observer_lon, sat_lon)
def motor_set_limit(self, direction: str) -> None:
with self._lock:
self._dev.motor_set_limit(direction)
def motor_disable_limits(self) -> None:
with self._lock:
self._dev.motor_disable_limits()
def get_last_error(self) -> int:
with self._lock:
return self._dev.get_last_error()
def get_last_error_str(self) -> str:
with self._lock:
return self._dev.get_last_error_str()
def get_stream_diag(self, reset: bool = False) -> dict:
with self._lock:
return self._dev.get_stream_diag(reset=reset)
def get_hotplug_status(self, reset: bool = False,
force_scan: bool = False) -> dict:
with self._lock:
return self._dev.get_hotplug_status(reset=reset,
force_scan=force_scan)

File diff suppressed because it is too large Load diff

View file

@ -1 +1 @@
"""Mode screens for SkyWalker-1 TUI."""
"""Mode screens for SkyWalker-1 TUI."""

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,215 +1,215 @@
"""L-Band screen — direct input spectrum analyzer with allocation annotations.
Same sweep mechanics as the spectrum screen, but with LNB disabled (direct input)
and band allocation overlays showing what service each frequency range belongs to.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker
from skywalker_lib import LBAND_ALLOCATIONS
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.waterfall import WaterfallDisplay
def _alloc_table(start: float, stop: float) -> str:
"""Build a Rich-markup allocation reference for the visible range."""
lines = ["[#00d4aa bold]L-Band Allocations in range:[/]"]
colors = ["#60a0c0", "#80b060", "#c0a050", "#a06080", "#50a0a0", "#a08060", "#6080a0"]
for i, (lo, hi, name) in enumerate(LBAND_ALLOCATIONS):
if lo < stop and hi > start:
overlap_lo = max(lo, start)
overlap_hi = min(hi, stop)
c = colors[i % len(colors)]
lines.append(f" [{c}]{overlap_lo:.0f}-{overlap_hi:.0f} MHz {name}[/]")
if len(lines) == 1:
lines.append(" [#506878](none in range)[/]")
return "\n".join(lines)
class LBandScreen(Container):
"""L-band direct input analyzer with allocation annotations."""
DEFAULT_CSS = """
LBandScreen {
layout: vertical;
}
LBandScreen #lband-main {
height: 1fr;
layout: horizontal;
}
LBandScreen #lband-plot-col {
width: 2fr;
layout: vertical;
}
LBandScreen #lband-info-col {
width: 1fr;
padding: 1;
background: #0e1420;
border-left: solid #1a2a3a;
layout: vertical;
}
LBandScreen #lband-alloc-panel {
height: auto;
padding: 1;
}
LBandScreen #lband-progress {
height: 3;
layout: horizontal;
padding: 0 2;
background: #0e1018;
}
LBandScreen #lband-progress Static {
width: auto;
margin: 1 1 0 0;
}
LBandScreen #lband-progress ProgressBar {
width: 1fr;
margin: 1 1 0 0;
}
LBandScreen #lband-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
LBandScreen #lband-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
LBandScreen #lband-controls Input {
width: 10;
margin: 0 1;
}
LBandScreen #lband-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._sweeping = False
self._sweep_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Horizontal(id="lband-main"):
with Vertical(id="lband-plot-col"):
yield SpectrumPlot(title="L-Band Spectrum (Direct Input)",
id="lband-plot")
yield WaterfallDisplay(title="Waterfall", id="lband-waterfall")
with Vertical(id="lband-info-col"):
yield Static(_alloc_table(950, 2150), id="lband-alloc-panel")
with Horizontal(id="lband-progress"):
yield Static("[#506878]Ready[/]", id="lband-status")
yield ProgressBar(total=100, show_eta=False, id="lband-pbar")
with Horizontal(id="lband-controls"):
yield Label("Start:")
yield Input("950", id="lband-start")
yield Label("Stop:")
yield Input("2150", id="lband-stop")
yield Label("Step:")
yield Input("2", id="lband-step")
yield Label("Dwell:")
yield Input("20", id="lband-dwell")
yield Button("23cm", id="lband-23cm-btn")
yield Button("Sweep", id="lband-sweep-btn", variant="success")
yield Button("Stop", id="lband-stop-btn", variant="error")
def on_show(self) -> None:
if self._bridge.is_demo and not self._sweeping:
self._start_sweep()
def on_hide(self) -> None:
self._stop_sweep()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "lband-sweep-btn":
self._start_sweep()
elif event.button.id == "lband-stop-btn":
self._stop_sweep()
elif event.button.id == "lband-23cm-btn":
self.query_one("#lband-start", Input).value = "1240"
self.query_one("#lband-stop", Input).value = "1300"
self.query_one("#lband-step", Input).value = "0.5"
# Update allocation display
self.query_one("#lband-alloc-panel", Static).update(
_alloc_table(1240, 1300)
)
def _start_sweep(self) -> None:
if self._sweeping:
return
self._sweeping = True
start = float(self.query_one("#lband-start", Input).value or "950")
stop = float(self.query_one("#lband-stop", Input).value or "2150")
step = float(self.query_one("#lband-step", Input).value or "2")
dwell = int(self.query_one("#lband-dwell", Input).value or "20")
# Update allocation panel for current range
self.query_one("#lband-alloc-panel", Static).update(
_alloc_table(start, stop)
)
self._sweep_worker = self._do_sweep(start, stop, step, dwell)
def _stop_sweep(self) -> None:
self._sweeping = False
if self._sweep_worker:
self._sweep_worker.cancel()
self._sweep_worker = None
@work(thread=True)
def _do_sweep(self, start: float, stop: float, step: float, dwell: int) -> None:
"""L-band sweep with LNB disabled."""
try:
self._bridge.ensure_booted()
# Disable LNB for direct input
self._bridge.configure_lnb(disable_lnb=True)
except Exception:
pass
def progress_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._update_progress, pct, freq)
self.app.call_from_thread(self._set_status, "Sweeping...")
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell, sr_ksps=20000, callback=progress_cb,
)
self.app.call_from_thread(self._show_results, freqs, powers, results)
self._sweeping = False
def _update_progress(self, pct: float, freq: float) -> None:
if not self.is_mounted:
return
self.query_one("#lband-pbar", ProgressBar).update(progress=pct)
self.query_one("#lband-status", Static).update(
f"[#00d4aa]{freq:.1f} MHz[/]"
)
def _set_status(self, msg: str) -> None:
if not self.is_mounted:
return
self.query_one("#lband-status", Static).update(f"[#506878]{msg}[/]")
def _show_results(self, freqs, powers, results) -> None:
if not self.is_mounted:
return
self.query_one("#lband-plot", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=0.0,
)
self.query_one("#lband-waterfall", WaterfallDisplay).add_sweep(powers)
self.query_one("#lband-status", Static).update("[#506878]Complete[/]")
self.query_one("#lband-pbar", ProgressBar).update(progress=100)
"""L-Band screen — direct input spectrum analyzer with allocation annotations.
Same sweep mechanics as the spectrum screen, but with LNB disabled (direct input)
and band allocation overlays showing what service each frequency range belongs to.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker
from skywalker_lib import LBAND_ALLOCATIONS
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.waterfall import WaterfallDisplay
def _alloc_table(start: float, stop: float) -> str:
"""Build a Rich-markup allocation reference for the visible range."""
lines = ["[#00d4aa bold]L-Band Allocations in range:[/]"]
colors = ["#60a0c0", "#80b060", "#c0a050", "#a06080", "#50a0a0", "#a08060", "#6080a0"]
for i, (lo, hi, name) in enumerate(LBAND_ALLOCATIONS):
if lo < stop and hi > start:
overlap_lo = max(lo, start)
overlap_hi = min(hi, stop)
c = colors[i % len(colors)]
lines.append(f" [{c}]{overlap_lo:.0f}-{overlap_hi:.0f} MHz {name}[/]")
if len(lines) == 1:
lines.append(" [#506878](none in range)[/]")
return "\n".join(lines)
class LBandScreen(Container):
"""L-band direct input analyzer with allocation annotations."""
DEFAULT_CSS = """
LBandScreen {
layout: vertical;
}
LBandScreen #lband-main {
height: 1fr;
layout: horizontal;
}
LBandScreen #lband-plot-col {
width: 2fr;
layout: vertical;
}
LBandScreen #lband-info-col {
width: 1fr;
padding: 1;
background: #0e1420;
border-left: solid #1a2a3a;
layout: vertical;
}
LBandScreen #lband-alloc-panel {
height: auto;
padding: 1;
}
LBandScreen #lband-progress {
height: 3;
layout: horizontal;
padding: 0 2;
background: #0e1018;
}
LBandScreen #lband-progress Static {
width: auto;
margin: 1 1 0 0;
}
LBandScreen #lband-progress ProgressBar {
width: 1fr;
margin: 1 1 0 0;
}
LBandScreen #lband-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
LBandScreen #lband-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
LBandScreen #lband-controls Input {
width: 10;
margin: 0 1;
}
LBandScreen #lband-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._sweeping = False
self._sweep_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Horizontal(id="lband-main"):
with Vertical(id="lband-plot-col"):
yield SpectrumPlot(title="L-Band Spectrum (Direct Input)",
id="lband-plot")
yield WaterfallDisplay(title="Waterfall", id="lband-waterfall")
with Vertical(id="lband-info-col"):
yield Static(_alloc_table(950, 2150), id="lband-alloc-panel")
with Horizontal(id="lband-progress"):
yield Static("[#506878]Ready[/]", id="lband-status")
yield ProgressBar(total=100, show_eta=False, id="lband-pbar")
with Horizontal(id="lband-controls"):
yield Label("Start:")
yield Input("950", id="lband-start")
yield Label("Stop:")
yield Input("2150", id="lband-stop")
yield Label("Step:")
yield Input("2", id="lband-step")
yield Label("Dwell:")
yield Input("20", id="lband-dwell")
yield Button("23cm", id="lband-23cm-btn")
yield Button("Sweep", id="lband-sweep-btn", variant="success")
yield Button("Stop", id="lband-stop-btn", variant="error")
def on_show(self) -> None:
if self._bridge.is_demo and not self._sweeping:
self._start_sweep()
def on_hide(self) -> None:
self._stop_sweep()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "lband-sweep-btn":
self._start_sweep()
elif event.button.id == "lband-stop-btn":
self._stop_sweep()
elif event.button.id == "lband-23cm-btn":
self.query_one("#lband-start", Input).value = "1240"
self.query_one("#lband-stop", Input).value = "1300"
self.query_one("#lband-step", Input).value = "0.5"
# Update allocation display
self.query_one("#lband-alloc-panel", Static).update(
_alloc_table(1240, 1300)
)
def _start_sweep(self) -> None:
if self._sweeping:
return
self._sweeping = True
start = float(self.query_one("#lband-start", Input).value or "950")
stop = float(self.query_one("#lband-stop", Input).value or "2150")
step = float(self.query_one("#lband-step", Input).value or "2")
dwell = int(self.query_one("#lband-dwell", Input).value or "20")
# Update allocation panel for current range
self.query_one("#lband-alloc-panel", Static).update(
_alloc_table(start, stop)
)
self._sweep_worker = self._do_sweep(start, stop, step, dwell)
def _stop_sweep(self) -> None:
self._sweeping = False
if self._sweep_worker:
self._sweep_worker.cancel()
self._sweep_worker = None
@work(thread=True)
def _do_sweep(self, start: float, stop: float, step: float, dwell: int) -> None:
"""L-band sweep with LNB disabled."""
try:
self._bridge.ensure_booted()
# Disable LNB for direct input
self._bridge.configure_lnb(disable_lnb=True)
except Exception:
pass
def progress_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._update_progress, pct, freq)
self.app.call_from_thread(self._set_status, "Sweeping...")
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell, sr_ksps=20000, callback=progress_cb,
)
self.app.call_from_thread(self._show_results, freqs, powers, results)
self._sweeping = False
def _update_progress(self, pct: float, freq: float) -> None:
if not self.is_mounted:
return
self.query_one("#lband-pbar", ProgressBar).update(progress=pct)
self.query_one("#lband-status", Static).update(
f"[#00d4aa]{freq:.1f} MHz[/]"
)
def _set_status(self, msg: str) -> None:
if not self.is_mounted:
return
self.query_one("#lband-status", Static).update(f"[#506878]{msg}[/]")
def _show_results(self, freqs, powers, results) -> None:
if not self.is_mounted:
return
self.query_one("#lband-plot", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=0.0,
)
self.query_one("#lband-waterfall", WaterfallDisplay).add_sweep(powers)
self.query_one("#lband-status", Static).update("[#506878]Complete[/]")
self.query_one("#lband-pbar", ProgressBar).update(progress=100)

View file

@ -1,205 +1,205 @@
"""Monitor screen — real-time signal strength at a single frequency.
This is the dish-alignment / signal-monitoring mode. It polls signal_monitor()
at a configurable rate and displays SNR, power, lock state, and a rolling
sparkline history.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class MonitorScreen(Container):
"""Real-time signal monitor with gauge and sparkline."""
DEFAULT_CSS = """
MonitorScreen {
layout: vertical;
}
MonitorScreen #monitor-main {
height: 1fr;
layout: vertical;
padding: 1 2;
}
MonitorScreen #monitor-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
MonitorScreen #monitor-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
MonitorScreen #monitor-controls Input {
width: 14;
margin: 0 1;
}
MonitorScreen #monitor-controls Button {
margin: 0 1;
}
MonitorScreen #monitor-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
MonitorScreen #monitor-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
BINDINGS = [
("space", "toggle_poll", "Start/Stop"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="monitor-main"):
yield SignalGauge(id="monitor-gauge")
yield SparklineWidget(title="SNR History", color="#00d4aa",
id="snr-sparkline")
yield SparklineWidget(title="Power History", color="#2196f3",
id="power-sparkline")
with Horizontal(id="monitor-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="stat-samples")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="stat-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="stat-status")
with Horizontal(id="monitor-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="mon-freq")
yield Label("SR (ksps):")
yield Input("20000", id="mon-sr")
yield Label("Rate (Hz):")
yield Input("5", id="mon-rate")
yield Button("Start", id="mon-start", variant="success")
yield Button("Stop", id="mon-stop", variant="error")
def on_show(self) -> None:
# Auto-start polling in demo mode
if self._bridge.is_demo and not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "mon-start":
self._start_polling()
elif event.button.id == "mon-stop":
self._stop_polling()
def action_toggle_poll(self) -> None:
if self._polling:
self._stop_polling()
else:
self._start_polling()
def _start_polling(self) -> None:
if self._polling:
return
self._polling = True
self._sample_count = 0
self._peak_snr = 0.0
freq_mhz = float(self.query_one("#mon-freq", Input).value or "1200")
sr_ksps = int(self.query_one("#mon-sr", Input).value or "20000")
rate = float(self.query_one("#mon-rate", Input).value or "5")
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Running[/]"
)
self._poll_worker = self._do_poll(freq_mhz, sr_ksps, rate)
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker is not None:
self._poll_worker.cancel()
self._poll_worker = None
try:
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
except Exception:
pass
@staticmethod
def _parse_input(input_widget: Input, default: float) -> float:
try:
return float(input_widget.value)
except (ValueError, TypeError):
return default
@work(thread=True)
def _do_poll(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background worker that polls signal_monitor() in a thread."""
import time
interval = 1.0 / max(0.5, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
# Initial tune
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception:
pass
while self._polling:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
self._peak_snr = max(self._peak_snr, snr_db)
# Post updates to the UI thread
self.app.call_from_thread(self._update_ui, sig)
elapsed = time.monotonic() - t0
sleep = interval - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict) -> None:
"""Called from the main thread to update widgets."""
if not self.is_mounted:
return
self.query_one("#monitor-gauge", SignalGauge).update_signal(sig)
self.query_one("#snr-sparkline", SparklineWidget).push(sig.get("snr_db", 0))
self.query_one("#power-sparkline", SparklineWidget).push(sig.get("power_db", -40))
self.query_one("#stat-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#stat-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)
"""Monitor screen — real-time signal strength at a single frequency.
This is the dish-alignment / signal-monitoring mode. It polls signal_monitor()
at a configurable rate and displays SNR, power, lock state, and a rolling
sparkline history.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class MonitorScreen(Container):
"""Real-time signal monitor with gauge and sparkline."""
DEFAULT_CSS = """
MonitorScreen {
layout: vertical;
}
MonitorScreen #monitor-main {
height: 1fr;
layout: vertical;
padding: 1 2;
}
MonitorScreen #monitor-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
MonitorScreen #monitor-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
MonitorScreen #monitor-controls Input {
width: 14;
margin: 0 1;
}
MonitorScreen #monitor-controls Button {
margin: 0 1;
}
MonitorScreen #monitor-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
MonitorScreen #monitor-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
BINDINGS = [
("space", "toggle_poll", "Start/Stop"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
def compose(self) -> ComposeResult:
with Vertical(id="monitor-main"):
yield SignalGauge(id="monitor-gauge")
yield SparklineWidget(title="SNR History", color="#00d4aa",
id="snr-sparkline")
yield SparklineWidget(title="Power History", color="#2196f3",
id="power-sparkline")
with Horizontal(id="monitor-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="stat-samples")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="stat-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="stat-status")
with Horizontal(id="monitor-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="mon-freq")
yield Label("SR (ksps):")
yield Input("20000", id="mon-sr")
yield Label("Rate (Hz):")
yield Input("5", id="mon-rate")
yield Button("Start", id="mon-start", variant="success")
yield Button("Stop", id="mon-stop", variant="error")
def on_show(self) -> None:
# Auto-start polling in demo mode
if self._bridge.is_demo and not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "mon-start":
self._start_polling()
elif event.button.id == "mon-stop":
self._stop_polling()
def action_toggle_poll(self) -> None:
if self._polling:
self._stop_polling()
else:
self._start_polling()
def _start_polling(self) -> None:
if self._polling:
return
self._polling = True
self._sample_count = 0
self._peak_snr = 0.0
freq_mhz = float(self.query_one("#mon-freq", Input).value or "1200")
sr_ksps = int(self.query_one("#mon-sr", Input).value or "20000")
rate = float(self.query_one("#mon-rate", Input).value or "5")
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Running[/]"
)
self._poll_worker = self._do_poll(freq_mhz, sr_ksps, rate)
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker is not None:
self._poll_worker.cancel()
self._poll_worker = None
try:
self.query_one("#stat-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
except Exception:
pass
@staticmethod
def _parse_input(input_widget: Input, default: float) -> float:
try:
return float(input_widget.value)
except (ValueError, TypeError):
return default
@work(thread=True)
def _do_poll(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background worker that polls signal_monitor() in a thread."""
import time
interval = 1.0 / max(0.5, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
# Initial tune
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception:
pass
while self._polling:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
self._peak_snr = max(self._peak_snr, snr_db)
# Post updates to the UI thread
self.app.call_from_thread(self._update_ui, sig)
elapsed = time.monotonic() - t0
sleep = interval - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict) -> None:
"""Called from the main thread to update widgets."""
if not self.is_mounted:
return
self.query_one("#monitor-gauge", SignalGauge).update_signal(sig)
self.query_one("#snr-sparkline", SparklineWidget).push(sig.get("snr_db", 0))
self.query_one("#power-sparkline", SparklineWidget).push(sig.get("power_db", -40))
self.query_one("#stat-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#stat-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)

View file

@ -1,411 +1,411 @@
"""Motor screen — DiSEqC 1.2 positioner control with live signal feedback.
Three-column layout: Motor Control (jog/halt/limits) | Positions (store/recall) |
USALS GotoX (calculator + presets). Bottom bar shows live signal monitor for
dish alignment feedback during jog.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, Grid
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
_QO100_SAT_LON = 25.9 # Es'hail-2
class MotorScreen(Container):
"""DiSEqC 1.2 positioner control with signal feedback."""
DEFAULT_CSS = """
MotorScreen {
layout: vertical;
}
MotorScreen #motor-main {
height: 1fr;
layout: horizontal;
}
MotorScreen .motor-col {
width: 1fr;
padding: 1;
}
MotorScreen .motor-panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
height: auto;
}
MotorScreen .motor-panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
MotorScreen .jog-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
MotorScreen .jog-row Button {
width: 1fr;
margin: 0 1 0 0;
}
MotorScreen .pos-grid {
layout: grid;
grid-size: 3;
grid-gutter: 1;
height: auto;
}
MotorScreen .pos-grid Button {
height: 3;
}
MotorScreen .pos-btn-stored {
background: #1a3a2a;
color: #00d4aa;
border: round #00d4aa;
}
MotorScreen .pos-btn-empty {
background: #121c2a;
color: #506878;
border: round #1a3050;
}
MotorScreen #motor-signal {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
MotorScreen #motor-signal .sig-row {
height: 3;
layout: horizontal;
}
MotorScreen #motor-signal Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row {
height: auto;
}
MotorScreen .usals-input-row Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row Input {
width: 12;
margin: 0 1;
}
MotorScreen .motor-status {
height: auto;
color: #506878;
margin: 1 0 0 0;
}
"""
BINDINGS = [
("left", "jog_west", "Jog West"),
("right", "jog_east", "Jog East"),
("space", "halt", "Halt"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._jog_active = False
self._jog_start_time = 0.0
self._stored_positions: dict[int, bool] = {}
def compose(self) -> ComposeResult:
with Horizontal(id="motor-main"):
# Column 1: Motor jog control
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Motor Control", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("West", id="jog-west", variant="warning")
yield Button("HALT", id="jog-halt", variant="error")
yield Button("East", id="jog-east", variant="warning")
with Horizontal(classes="jog-row"):
yield Button("Step W", id="step-west")
yield Button("Step E", id="step-east")
yield Static("[#506878]Direction:[/] [#e8a020]Stopped[/]",
id="motor-dir-status", classes="motor-status")
yield Static("[#506878]Position:[/] [#00d4aa]0.0 deg[/]",
id="motor-pos-status", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Limits", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("Set East Limit", id="limit-east")
yield Button("Set West Limit", id="limit-west")
yield Button("Disable Limits", id="limit-disable")
# Column 2: Stored positions
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Stored Positions", classes="motor-panel-title")
yield Static(
"[#506878]Press to recall, hold S+number to store[/]",
classes="motor-status",
)
with Grid(classes="pos-grid"):
for i in range(1, 10):
yield Button(
f"Pos {i}",
id=f"pos-{i}",
classes="pos-btn-empty",
)
yield Button("Go to Reference (0)", id="pos-ref")
yield Static("", id="pos-info", classes="motor-status")
# Column 3: USALS GotoX
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("USALS GotoX", classes="motor-panel-title")
with Horizontal(classes="usals-input-row"):
yield Label("Observer Lon:")
yield Input("-97.5", id="usals-obs-lon")
with Horizontal(classes="usals-input-row"):
yield Label("Satellite Lon:")
yield Input("25.9", id="usals-sat-lon")
yield Button("Calculate & Go", id="usals-go", variant="success")
yield Static("", id="usals-result", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Presets", classes="motor-panel-title")
yield Button("QO-100 (25.9E)", id="preset-qo100")
yield Button("Galaxy 19 (97.0W)", id="preset-g19")
yield Button("AMC-1 (103.0W)", id="preset-amc1")
# Bottom: live signal bar
with Vertical(id="motor-signal"):
yield SignalGauge(id="motor-gauge")
with Horizontal(classes="sig-row"):
yield Static("[#506878]SNR:[/] [#00d4aa]-- dB[/]", id="sig-snr")
yield Static("[#506878]Power:[/] [#00d4aa]-- dB[/]", id="sig-power")
yield Static("[#506878]Lock:[/] [#e04040]NO[/]", id="sig-lock")
yield Static("[#506878]Motor:[/] [#e8a020]Idle[/]", id="sig-motor")
def on_show(self) -> None:
if not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
# Safety: halt motor when leaving screen
if self._jog_active:
try:
self._bridge.motor_halt()
except Exception:
pass
self._jog_active = False
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "jog-east":
self._do_jog_east()
elif btn == "jog-west":
self._do_jog_west()
elif btn == "jog-halt":
self._do_halt()
elif btn == "step-east":
self._do_step(east=True)
elif btn == "step-west":
self._do_step(east=False)
elif btn == "limit-east":
self._bridge.motor_set_limit("east")
elif btn == "limit-west":
self._bridge.motor_set_limit("west")
elif btn == "limit-disable":
self._bridge.motor_disable_limits()
elif btn.startswith("pos-") and btn != "pos-ref":
slot = int(btn.split("-")[1])
self._do_goto_position(slot)
elif btn == "pos-ref":
self._do_goto_position(0)
elif btn == "usals-go":
self._do_usals_go()
elif btn == "preset-qo100":
self._do_preset(25.9)
elif btn == "preset-g19":
self._do_preset(-97.0)
elif btn == "preset-amc1":
self._do_preset(-103.0)
def action_jog_east(self) -> None:
self._do_jog_east()
def action_jog_west(self) -> None:
self._do_jog_west()
def action_halt(self) -> None:
self._do_halt()
def _do_jog_east(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_east()
self._update_dir_status("East", "#00e060")
def _do_jog_west(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_west()
self._update_dir_status("West", "#2196f3")
def _do_halt(self) -> None:
self._jog_active = False
self._bridge.motor_halt()
self._update_dir_status("Stopped", "#e8a020")
def _do_step(self, east: bool) -> None:
if east:
self._bridge.motor_drive_east(steps=10)
else:
self._bridge.motor_drive_west(steps=10)
self._update_dir_status("Stepping", "#e8a020")
def _do_goto_position(self, slot: int) -> None:
self._bridge.motor_goto_position(slot)
label = f"Pos {slot}" if slot > 0 else "Reference"
self._update_dir_status(f"Going to {label}", "#00d4aa")
def _do_usals_go(self) -> None:
try:
obs_lon = float(self.query_one("#usals-obs-lon", Input).value)
sat_lon = float(self.query_one("#usals-sat-lon", Input).value)
except (ValueError, TypeError):
return
self._bridge.motor_goto_x(obs_lon, sat_lon)
# Show calculated angle
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
try:
from skywalker_lib import usals_angle
angle = usals_angle(obs_lon, sat_lon)
direction = "East" if angle >= 0 else "West"
self.query_one("#usals-result", Static).update(
f"[#506878]Angle:[/] [#00d4aa]{abs(angle):.1f} deg {direction}[/]"
)
except ImportError:
pass
self._update_dir_status("USALS GotoX", "#00d4aa")
def _do_preset(self, sat_lon: float) -> None:
self.query_one("#usals-sat-lon", Input).value = str(sat_lon)
self._do_usals_go()
def _update_dir_status(self, text: str, color: str) -> None:
if not self.is_mounted:
return
self.query_one("#motor-dir-status", Static).update(
f"[#506878]Direction:[/] [{color}]{text}[/]"
)
def _start_polling(self) -> None:
self._polling = True
self._poll_worker = self._do_signal_poll()
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker:
self._poll_worker.cancel()
self._poll_worker = None
@work(thread=True)
def _do_signal_poll(self) -> None:
"""Poll signal + motor state at ~2 Hz for alignment feedback."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
while self._polling:
t0 = time.monotonic()
# Safety: auto-halt after 30s continuous jog
if self._jog_active:
elapsed = t0 - self._jog_start_time
if elapsed > 30.0:
self._bridge.motor_halt()
self._jog_active = False
self.app.call_from_thread(
self._update_dir_status, "Auto-halted (30s)", "#e04040"
)
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(0.5)
continue
# Read motor position from demo device
motor_pos = None
motor_moving = False
if hasattr(self._bridge, '_dev'):
dev = self._bridge._dev
if hasattr(dev, 'motor_position'):
motor_pos = dev.motor_position
motor_moving = dev.motor_is_moving
self.app.call_from_thread(self._update_signal_ui, sig, motor_pos, motor_moving)
elapsed = time.monotonic() - t0
sleep = 0.5 - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_signal_ui(self, sig: dict, motor_pos: float | None,
motor_moving: bool) -> None:
if not self.is_mounted:
return
self.query_one("#motor-gauge", SignalGauge).update_signal(sig)
snr = sig.get("snr_db", 0.0)
power = sig.get("power_db", -40.0)
locked = sig.get("locked", False)
self.query_one("#sig-snr", Static).update(
f"[#506878]SNR:[/] [#00d4aa]{snr:.1f} dB[/]"
)
self.query_one("#sig-power", Static).update(
f"[#506878]Power:[/] [#00d4aa]{power:.1f} dB[/]"
)
lock_color = "#00e060" if locked else "#e04040"
lock_text = "LOCKED" if locked else "NO"
self.query_one("#sig-lock", Static).update(
f"[#506878]Lock:[/] [{lock_color}]{lock_text}[/]"
)
if motor_pos is not None:
direction = "E" if motor_pos >= 0 else "W"
move_indicator = " [#e8a020]>>>[/]" if motor_moving else ""
self.query_one("#sig-motor", Static).update(
f"[#506878]Motor:[/] [#00d4aa]{abs(motor_pos):.1f} deg {direction}[/]{move_indicator}"
)
self.query_one("#motor-pos-status", Static).update(
f"[#506878]Position:[/] [#00d4aa]{motor_pos:.1f} deg[/]"
)
if not self._jog_active and not motor_moving:
self._update_dir_status("Stopped", "#e8a020")
"""Motor screen — DiSEqC 1.2 positioner control with live signal feedback.
Three-column layout: Motor Control (jog/halt/limits) | Positions (store/recall) |
USALS GotoX (calculator + presets). Bottom bar shows live signal monitor for
dish alignment feedback during jog.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, Grid
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.signal_gauge import SignalGauge
_QO100_SAT_LON = 25.9 # Es'hail-2
class MotorScreen(Container):
"""DiSEqC 1.2 positioner control with signal feedback."""
DEFAULT_CSS = """
MotorScreen {
layout: vertical;
}
MotorScreen #motor-main {
height: 1fr;
layout: horizontal;
}
MotorScreen .motor-col {
width: 1fr;
padding: 1;
}
MotorScreen .motor-panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
height: auto;
}
MotorScreen .motor-panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
MotorScreen .jog-row {
height: auto;
layout: horizontal;
margin: 0 0 1 0;
}
MotorScreen .jog-row Button {
width: 1fr;
margin: 0 1 0 0;
}
MotorScreen .pos-grid {
layout: grid;
grid-size: 3;
grid-gutter: 1;
height: auto;
}
MotorScreen .pos-grid Button {
height: 3;
}
MotorScreen .pos-btn-stored {
background: #1a3a2a;
color: #00d4aa;
border: round #00d4aa;
}
MotorScreen .pos-btn-empty {
background: #121c2a;
color: #506878;
border: round #1a3050;
}
MotorScreen #motor-signal {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
MotorScreen #motor-signal .sig-row {
height: 3;
layout: horizontal;
}
MotorScreen #motor-signal Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row {
height: auto;
}
MotorScreen .usals-input-row Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
MotorScreen .usals-input-row Input {
width: 12;
margin: 0 1;
}
MotorScreen .motor-status {
height: auto;
color: #506878;
margin: 1 0 0 0;
}
"""
BINDINGS = [
("left", "jog_west", "Jog West"),
("right", "jog_east", "Jog East"),
("space", "halt", "Halt"),
]
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._polling = False
self._poll_worker: Worker | None = None
self._jog_active = False
self._jog_start_time = 0.0
self._stored_positions: dict[int, bool] = {}
def compose(self) -> ComposeResult:
with Horizontal(id="motor-main"):
# Column 1: Motor jog control
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Motor Control", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("West", id="jog-west", variant="warning")
yield Button("HALT", id="jog-halt", variant="error")
yield Button("East", id="jog-east", variant="warning")
with Horizontal(classes="jog-row"):
yield Button("Step W", id="step-west")
yield Button("Step E", id="step-east")
yield Static("[#506878]Direction:[/] [#e8a020]Stopped[/]",
id="motor-dir-status", classes="motor-status")
yield Static("[#506878]Position:[/] [#00d4aa]0.0 deg[/]",
id="motor-pos-status", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Limits", classes="motor-panel-title")
with Horizontal(classes="jog-row"):
yield Button("Set East Limit", id="limit-east")
yield Button("Set West Limit", id="limit-west")
yield Button("Disable Limits", id="limit-disable")
# Column 2: Stored positions
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("Stored Positions", classes="motor-panel-title")
yield Static(
"[#506878]Press to recall, hold S+number to store[/]",
classes="motor-status",
)
with Grid(classes="pos-grid"):
for i in range(1, 10):
yield Button(
f"Pos {i}",
id=f"pos-{i}",
classes="pos-btn-empty",
)
yield Button("Go to Reference (0)", id="pos-ref")
yield Static("", id="pos-info", classes="motor-status")
# Column 3: USALS GotoX
with Vertical(classes="motor-col"):
with Vertical(classes="motor-panel"):
yield Label("USALS GotoX", classes="motor-panel-title")
with Horizontal(classes="usals-input-row"):
yield Label("Observer Lon:")
yield Input("-97.5", id="usals-obs-lon")
with Horizontal(classes="usals-input-row"):
yield Label("Satellite Lon:")
yield Input("25.9", id="usals-sat-lon")
yield Button("Calculate & Go", id="usals-go", variant="success")
yield Static("", id="usals-result", classes="motor-status")
with Vertical(classes="motor-panel"):
yield Label("Presets", classes="motor-panel-title")
yield Button("QO-100 (25.9E)", id="preset-qo100")
yield Button("Galaxy 19 (97.0W)", id="preset-g19")
yield Button("AMC-1 (103.0W)", id="preset-amc1")
# Bottom: live signal bar
with Vertical(id="motor-signal"):
yield SignalGauge(id="motor-gauge")
with Horizontal(classes="sig-row"):
yield Static("[#506878]SNR:[/] [#00d4aa]-- dB[/]", id="sig-snr")
yield Static("[#506878]Power:[/] [#00d4aa]-- dB[/]", id="sig-power")
yield Static("[#506878]Lock:[/] [#e04040]NO[/]", id="sig-lock")
yield Static("[#506878]Motor:[/] [#e8a020]Idle[/]", id="sig-motor")
def on_show(self) -> None:
if not self._polling:
self._start_polling()
def on_hide(self) -> None:
self._stop_polling()
# Safety: halt motor when leaving screen
if self._jog_active:
try:
self._bridge.motor_halt()
except Exception:
pass
self._jog_active = False
def on_button_pressed(self, event: Button.Pressed) -> None:
btn = event.button.id or ""
if btn == "jog-east":
self._do_jog_east()
elif btn == "jog-west":
self._do_jog_west()
elif btn == "jog-halt":
self._do_halt()
elif btn == "step-east":
self._do_step(east=True)
elif btn == "step-west":
self._do_step(east=False)
elif btn == "limit-east":
self._bridge.motor_set_limit("east")
elif btn == "limit-west":
self._bridge.motor_set_limit("west")
elif btn == "limit-disable":
self._bridge.motor_disable_limits()
elif btn.startswith("pos-") and btn != "pos-ref":
slot = int(btn.split("-")[1])
self._do_goto_position(slot)
elif btn == "pos-ref":
self._do_goto_position(0)
elif btn == "usals-go":
self._do_usals_go()
elif btn == "preset-qo100":
self._do_preset(25.9)
elif btn == "preset-g19":
self._do_preset(-97.0)
elif btn == "preset-amc1":
self._do_preset(-103.0)
def action_jog_east(self) -> None:
self._do_jog_east()
def action_jog_west(self) -> None:
self._do_jog_west()
def action_halt(self) -> None:
self._do_halt()
def _do_jog_east(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_east()
self._update_dir_status("East", "#00e060")
def _do_jog_west(self) -> None:
import time
self._jog_active = True
self._jog_start_time = time.monotonic()
self._bridge.motor_drive_west()
self._update_dir_status("West", "#2196f3")
def _do_halt(self) -> None:
self._jog_active = False
self._bridge.motor_halt()
self._update_dir_status("Stopped", "#e8a020")
def _do_step(self, east: bool) -> None:
if east:
self._bridge.motor_drive_east(steps=10)
else:
self._bridge.motor_drive_west(steps=10)
self._update_dir_status("Stepping", "#e8a020")
def _do_goto_position(self, slot: int) -> None:
self._bridge.motor_goto_position(slot)
label = f"Pos {slot}" if slot > 0 else "Reference"
self._update_dir_status(f"Going to {label}", "#00d4aa")
def _do_usals_go(self) -> None:
try:
obs_lon = float(self.query_one("#usals-obs-lon", Input).value)
sat_lon = float(self.query_one("#usals-sat-lon", Input).value)
except (ValueError, TypeError):
return
self._bridge.motor_goto_x(obs_lon, sat_lon)
# Show calculated angle
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
try:
from skywalker_lib import usals_angle
angle = usals_angle(obs_lon, sat_lon)
direction = "East" if angle >= 0 else "West"
self.query_one("#usals-result", Static).update(
f"[#506878]Angle:[/] [#00d4aa]{abs(angle):.1f} deg {direction}[/]"
)
except ImportError:
pass
self._update_dir_status("USALS GotoX", "#00d4aa")
def _do_preset(self, sat_lon: float) -> None:
self.query_one("#usals-sat-lon", Input).value = str(sat_lon)
self._do_usals_go()
def _update_dir_status(self, text: str, color: str) -> None:
if not self.is_mounted:
return
self.query_one("#motor-dir-status", Static).update(
f"[#506878]Direction:[/] [{color}]{text}[/]"
)
def _start_polling(self) -> None:
self._polling = True
self._poll_worker = self._do_signal_poll()
def _stop_polling(self) -> None:
self._polling = False
if self._poll_worker:
self._poll_worker.cancel()
self._poll_worker = None
@work(thread=True)
def _do_signal_poll(self) -> None:
"""Poll signal + motor state at ~2 Hz for alignment feedback."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
while self._polling:
t0 = time.monotonic()
# Safety: auto-halt after 30s continuous jog
if self._jog_active:
elapsed = t0 - self._jog_start_time
if elapsed > 30.0:
self._bridge.motor_halt()
self._jog_active = False
self.app.call_from_thread(
self._update_dir_status, "Auto-halted (30s)", "#e04040"
)
try:
sig = self._bridge.signal_monitor()
except Exception:
time.sleep(0.5)
continue
# Read motor position from demo device
motor_pos = None
motor_moving = False
if hasattr(self._bridge, '_dev'):
dev = self._bridge._dev
if hasattr(dev, 'motor_position'):
motor_pos = dev.motor_position
motor_moving = dev.motor_is_moving
self.app.call_from_thread(self._update_signal_ui, sig, motor_pos, motor_moving)
elapsed = time.monotonic() - t0
sleep = 0.5 - elapsed
if sleep > 0:
time.sleep(sleep)
def _update_signal_ui(self, sig: dict, motor_pos: float | None,
motor_moving: bool) -> None:
if not self.is_mounted:
return
self.query_one("#motor-gauge", SignalGauge).update_signal(sig)
snr = sig.get("snr_db", 0.0)
power = sig.get("power_db", -40.0)
locked = sig.get("locked", False)
self.query_one("#sig-snr", Static).update(
f"[#506878]SNR:[/] [#00d4aa]{snr:.1f} dB[/]"
)
self.query_one("#sig-power", Static).update(
f"[#506878]Power:[/] [#00d4aa]{power:.1f} dB[/]"
)
lock_color = "#00e060" if locked else "#e04040"
lock_text = "LOCKED" if locked else "NO"
self.query_one("#sig-lock", Static).update(
f"[#506878]Lock:[/] [{lock_color}]{lock_text}[/]"
)
if motor_pos is not None:
direction = "E" if motor_pos >= 0 else "W"
move_indicator = " [#e8a020]>>>[/]" if motor_moving else ""
self.query_one("#sig-motor", Static).update(
f"[#506878]Motor:[/] [#00d4aa]{abs(motor_pos):.1f} deg {direction}[/]{move_indicator}"
)
self.query_one("#motor-pos-status", Static).update(
f"[#506878]Position:[/] [#00d4aa]{motor_pos:.1f} deg[/]"
)
if not self._jog_active and not motor_moving:
self._update_dir_status("Stopped", "#e8a020")

View file

@ -1,257 +1,257 @@
"""Scan screen — automated transponder discovery.
Multi-phase pipeline: coarse sweep peak detection fine sweep blind scan.
Shows progress, spectrum visualization, and a results table.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.frequency_table import FrequencyTable
class ScanScreen(Container):
"""Multi-phase transponder scanner with progress and results table."""
DEFAULT_CSS = """
ScanScreen {
layout: vertical;
}
ScanScreen #scan-main {
height: 1fr;
layout: vertical;
}
ScanScreen #scan-upper {
height: 1fr;
layout: horizontal;
}
ScanScreen #scan-spectrum-col {
width: 1fr;
}
ScanScreen #scan-results-col {
width: 1fr;
}
ScanScreen #scan-progress {
height: auto;
padding: 1 2;
background: #0e1018;
layout: vertical;
}
ScanScreen #scan-progress-row {
height: 3;
layout: horizontal;
}
ScanScreen #scan-progress Static {
width: auto;
margin: 0 1 0 0;
}
ScanScreen #scan-progress ProgressBar {
width: 1fr;
margin: 0 1;
}
ScanScreen #scan-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
ScanScreen #scan-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
ScanScreen #scan-controls Input {
width: 10;
margin: 0 1;
}
ScanScreen #scan-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Vertical(id="scan-main"):
with Horizontal(id="scan-upper"):
with Vertical(id="scan-spectrum-col"):
yield SpectrumPlot(title="Coarse Sweep", id="scan-spectrum")
with Vertical(id="scan-results-col"):
yield Static("[#00d4aa bold]Transponders Found[/]",
id="scan-results-title")
yield FrequencyTable(id="scan-table")
with Vertical(id="scan-progress"):
yield Static("[#506878]Ready[/]", id="scan-phase")
with Horizontal(id="scan-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="scan-pbar")
with Horizontal(id="scan-controls"):
yield Label("Start:")
yield Input("950", id="scan-start")
yield Label("Stop:")
yield Input("2150", id="scan-stop")
yield Label("LNB LO:")
yield Input("9750", id="scan-lnb")
yield Label("Threshold:")
yield Input("3", id="scan-thresh")
yield Button("Scan", id="scan-start-btn", variant="success")
yield Button("Stop", id="scan-stop-btn", variant="error")
def on_hide(self) -> None:
self._stop_scan()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "scan-start-btn":
self._start_scan()
elif event.button.id == "scan-stop-btn":
self._stop_scan()
def _start_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#scan-start", Input).value or "950")
stop = float(self.query_one("#scan-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#scan-lnb", Input).value or "9750")
threshold = float(self.query_one("#scan-thresh", Input).value or "3")
# Clear previous results
self.query_one("#scan-table", FrequencyTable).clear_table()
self._scan_worker = self._do_scan(start, stop, lnb_lo, threshold)
def _stop_scan(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
@work(thread=True)
def _do_scan(self, start: float, stop: float, lnb_lo: float,
threshold: float) -> None:
"""Multi-phase scan pipeline in a background thread."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
# Phase 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Phase 1: Coarse sweep", 0)
coarse_step = 10
def coarse_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, coarse_step, dwell_ms=15, sr_ksps=20000,
callback=coarse_cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
# Phase 2: Peak detection
self.app.call_from_thread(self._set_phase, "Phase 2: Peak detection", 50)
peaks = detect_peaks(freqs, powers, threshold_db=threshold)
if not peaks:
self.app.call_from_thread(self._set_phase, "No peaks found", 100)
self._scanning = False
return
# Phase 3: Fine sweep around peaks
self.app.call_from_thread(
self._set_phase,
f"Phase 3: Fine sweep ({len(peaks)} peaks)", 60,
)
refined = []
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
fine_start = max(start, freq - 15)
fine_stop = min(stop, freq + 15)
fine_freqs, fine_powers, fine_results = self._bridge.sweep_spectrum(
fine_start, fine_stop, step_mhz=2.0, dwell_ms=20, sr_ksps=20000,
)
if fine_powers:
best_idx = fine_powers.index(max(fine_powers))
refined.append((
fine_freqs[best_idx], fine_powers[best_idx],
fine_results[best_idx],
))
pct = 60 + (i + 1) / len(peaks) * 20
self.app.call_from_thread(self._set_progress, pct)
# Phase 4: Blind scan
self.app.call_from_thread(
self._set_phase,
f"Phase 4: Blind scan ({len(refined)} candidates)", 80,
)
sr_min = 1000 * 1000
sr_max = 30000 * 1000
sr_step = 500 * 1000
for i, (freq, pwr, result) in enumerate(refined):
if not self._scanning:
return
freq_khz = int(freq * 1000)
bs_result = self._bridge.blind_scan(freq_khz, sr_min, sr_max, sr_step)
if bs_result and bs_result.get("locked"):
tp = {
"if_mhz": bs_result.get("freq_khz", freq_khz) / 1000.0,
"rf_mhz": if_to_rf(
bs_result.get("freq_khz", freq_khz) / 1000.0, lnb_lo
),
"sr_ksps": bs_result.get("sr_sps", 0) // 1000,
"power_db": pwr,
"locked": True,
}
self.app.call_from_thread(self._add_transponder, tp)
pct = 80 + (i + 1) / len(refined) * 20
self.app.call_from_thread(self._set_progress, pct)
self.app.call_from_thread(self._set_phase, "Scan complete", 100)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#scan-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#scan-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_transponder(self, tp: dict) -> None:
if not self.is_mounted:
return
self.query_one("#scan-table", FrequencyTable).add_transponder(tp)
"""Scan screen — automated transponder discovery.
Multi-phase pipeline: coarse sweep peak detection fine sweep blind scan.
Shows progress, spectrum visualization, and a results table.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.frequency_table import FrequencyTable
class ScanScreen(Container):
"""Multi-phase transponder scanner with progress and results table."""
DEFAULT_CSS = """
ScanScreen {
layout: vertical;
}
ScanScreen #scan-main {
height: 1fr;
layout: vertical;
}
ScanScreen #scan-upper {
height: 1fr;
layout: horizontal;
}
ScanScreen #scan-spectrum-col {
width: 1fr;
}
ScanScreen #scan-results-col {
width: 1fr;
}
ScanScreen #scan-progress {
height: auto;
padding: 1 2;
background: #0e1018;
layout: vertical;
}
ScanScreen #scan-progress-row {
height: 3;
layout: horizontal;
}
ScanScreen #scan-progress Static {
width: auto;
margin: 0 1 0 0;
}
ScanScreen #scan-progress ProgressBar {
width: 1fr;
margin: 0 1;
}
ScanScreen #scan-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
ScanScreen #scan-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
ScanScreen #scan-controls Input {
width: 10;
margin: 0 1;
}
ScanScreen #scan-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._scanning = False
self._scan_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Vertical(id="scan-main"):
with Horizontal(id="scan-upper"):
with Vertical(id="scan-spectrum-col"):
yield SpectrumPlot(title="Coarse Sweep", id="scan-spectrum")
with Vertical(id="scan-results-col"):
yield Static("[#00d4aa bold]Transponders Found[/]",
id="scan-results-title")
yield FrequencyTable(id="scan-table")
with Vertical(id="scan-progress"):
yield Static("[#506878]Ready[/]", id="scan-phase")
with Horizontal(id="scan-progress-row"):
yield ProgressBar(total=100, show_eta=False, id="scan-pbar")
with Horizontal(id="scan-controls"):
yield Label("Start:")
yield Input("950", id="scan-start")
yield Label("Stop:")
yield Input("2150", id="scan-stop")
yield Label("LNB LO:")
yield Input("9750", id="scan-lnb")
yield Label("Threshold:")
yield Input("3", id="scan-thresh")
yield Button("Scan", id="scan-start-btn", variant="success")
yield Button("Stop", id="scan-stop-btn", variant="error")
def on_hide(self) -> None:
self._stop_scan()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "scan-start-btn":
self._start_scan()
elif event.button.id == "scan-stop-btn":
self._stop_scan()
def _start_scan(self) -> None:
if self._scanning:
return
self._scanning = True
start = float(self.query_one("#scan-start", Input).value or "950")
stop = float(self.query_one("#scan-stop", Input).value or "2150")
lnb_lo = float(self.query_one("#scan-lnb", Input).value or "9750")
threshold = float(self.query_one("#scan-thresh", Input).value or "3")
# Clear previous results
self.query_one("#scan-table", FrequencyTable).clear_table()
self._scan_worker = self._do_scan(start, stop, lnb_lo, threshold)
def _stop_scan(self) -> None:
self._scanning = False
if self._scan_worker:
self._scan_worker.cancel()
self._scan_worker = None
@work(thread=True)
def _do_scan(self, start: float, stop: float, lnb_lo: float,
threshold: float) -> None:
"""Multi-phase scan pipeline in a background thread."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "tools"))
from skywalker_lib import detect_peaks, if_to_rf
try:
self._bridge.ensure_booted()
except Exception:
pass
# Phase 1: Coarse sweep
self.app.call_from_thread(self._set_phase, "Phase 1: Coarse sweep", 0)
coarse_step = 10
def coarse_cb(freq, step_num, total, result):
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._set_progress, pct)
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, coarse_step, dwell_ms=15, sr_ksps=20000,
callback=coarse_cb,
)
if not self._scanning:
return
self.app.call_from_thread(self._update_spectrum, freqs, powers, results, lnb_lo)
# Phase 2: Peak detection
self.app.call_from_thread(self._set_phase, "Phase 2: Peak detection", 50)
peaks = detect_peaks(freqs, powers, threshold_db=threshold)
if not peaks:
self.app.call_from_thread(self._set_phase, "No peaks found", 100)
self._scanning = False
return
# Phase 3: Fine sweep around peaks
self.app.call_from_thread(
self._set_phase,
f"Phase 3: Fine sweep ({len(peaks)} peaks)", 60,
)
refined = []
for i, (freq, pwr, idx) in enumerate(peaks):
if not self._scanning:
return
fine_start = max(start, freq - 15)
fine_stop = min(stop, freq + 15)
fine_freqs, fine_powers, fine_results = self._bridge.sweep_spectrum(
fine_start, fine_stop, step_mhz=2.0, dwell_ms=20, sr_ksps=20000,
)
if fine_powers:
best_idx = fine_powers.index(max(fine_powers))
refined.append((
fine_freqs[best_idx], fine_powers[best_idx],
fine_results[best_idx],
))
pct = 60 + (i + 1) / len(peaks) * 20
self.app.call_from_thread(self._set_progress, pct)
# Phase 4: Blind scan
self.app.call_from_thread(
self._set_phase,
f"Phase 4: Blind scan ({len(refined)} candidates)", 80,
)
sr_min = 1000 * 1000
sr_max = 30000 * 1000
sr_step = 500 * 1000
for i, (freq, pwr, result) in enumerate(refined):
if not self._scanning:
return
freq_khz = int(freq * 1000)
bs_result = self._bridge.blind_scan(freq_khz, sr_min, sr_max, sr_step)
if bs_result and bs_result.get("locked"):
tp = {
"if_mhz": bs_result.get("freq_khz", freq_khz) / 1000.0,
"rf_mhz": if_to_rf(
bs_result.get("freq_khz", freq_khz) / 1000.0, lnb_lo
),
"sr_ksps": bs_result.get("sr_sps", 0) // 1000,
"power_db": pwr,
"locked": True,
}
self.app.call_from_thread(self._add_transponder, tp)
pct = 80 + (i + 1) / len(refined) * 20
self.app.call_from_thread(self._set_progress, pct)
self.app.call_from_thread(self._set_phase, "Scan complete", 100)
self._scanning = False
def _set_phase(self, text: str, progress: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-phase", Static).update(f"[#00d4aa]{text}[/]")
self.query_one("#scan-pbar", ProgressBar).update(progress=progress)
def _set_progress(self, pct: float) -> None:
if not self.is_mounted:
return
self.query_one("#scan-pbar", ProgressBar).update(progress=pct)
def _update_spectrum(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#scan-spectrum", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _add_transponder(self, tp: dict) -> None:
if not self.is_mounted:
return
self.query_one("#scan-table", FrequencyTable).add_transponder(tp)

View file

@ -1,206 +1,206 @@
"""Spectrum screen — sweep analyzer across the IF range.
Displays a bar chart of power vs. frequency, optionally with a rolling
waterfall beneath it. Uses threaded workers for the blocking USB sweep.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar, Checkbox
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.waterfall import WaterfallDisplay
class SpectrumScreen(Container):
"""Spectrum analyzer with bar chart and optional waterfall."""
DEFAULT_CSS = """
SpectrumScreen {
layout: vertical;
}
SpectrumScreen #spec-main {
height: 1fr;
layout: vertical;
}
SpectrumScreen #spec-progress-row {
height: 3;
layout: horizontal;
padding: 0 2;
background: #0e1018;
}
SpectrumScreen #spec-progress-row Static {
width: auto;
margin: 1 1 0 0;
}
SpectrumScreen #spec-progress-row ProgressBar {
width: 1fr;
margin: 1 1 0 0;
}
SpectrumScreen #spec-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
SpectrumScreen #spec-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
SpectrumScreen #spec-controls Input {
width: 10;
margin: 0 1;
}
SpectrumScreen #spec-controls Button {
margin: 0 1;
}
SpectrumScreen #spec-controls Checkbox {
margin: 1 1 0 0;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._sweeping = False
self._sweep_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Vertical(id="spec-main"):
yield SpectrumPlot(title="Spectrum Analyzer", id="spec-plot")
yield WaterfallDisplay(title="Waterfall", id="spec-waterfall")
with Horizontal(id="spec-progress-row"):
yield Static("[#506878]Ready[/]", id="spec-status")
yield ProgressBar(total=100, show_eta=False, id="spec-pbar")
with Horizontal(id="spec-controls"):
yield Label("Start:")
yield Input("950", id="spec-start")
yield Label("Stop:")
yield Input("2150", id="spec-stop")
yield Label("Step:")
yield Input("5", id="spec-step")
yield Label("Dwell:")
yield Input("10", id="spec-dwell")
yield Label("LNB LO:")
yield Input("0", id="spec-lnb")
yield Checkbox("Continuous", id="spec-continuous")
yield Button("Sweep", id="spec-sweep-btn", variant="success")
yield Button("Stop", id="spec-stop-btn", variant="error")
def on_show(self) -> None:
if self._bridge.is_demo and not self._sweeping:
self._start_sweep()
def on_hide(self) -> None:
self._stop_sweep()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "spec-sweep-btn":
self._start_sweep()
elif event.button.id == "spec-stop-btn":
self._stop_sweep()
def _start_sweep(self) -> None:
if self._sweeping:
return
# Validate inputs
try:
start = float(self.query_one("#spec-start", Input).value or "950")
stop = float(self.query_one("#spec-stop", Input).value or "2150")
step = float(self.query_one("#spec-step", Input).value or "5")
dwell = int(float(self.query_one("#spec-dwell", Input).value or "10"))
lnb_lo = float(self.query_one("#spec-lnb", Input).value or "0")
except ValueError:
self._update_status("Invalid input — check numeric fields")
return
if not (950 <= start <= 2150) or not (950 <= stop <= 2150):
self._update_status("Frequency out of range (9502150 MHz)")
return
if start >= stop:
self._update_status("Start must be less than Stop")
return
if not (0.1 <= step <= 500):
self._update_status("Step out of range (0.1500 MHz)")
return
continuous = self.query_one("#spec-continuous", Checkbox).value
self._sweeping = True
self._sweep_worker = self._do_sweep(start, stop, step, dwell, lnb_lo, continuous)
def _stop_sweep(self) -> None:
self._sweeping = False
if self._sweep_worker:
self._sweep_worker.cancel()
self._sweep_worker = None
@work(thread=True)
def _do_sweep(self, start: float, stop: float, step: float,
dwell: int, lnb_lo: float, continuous: bool) -> None:
"""Background sweep worker."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
sweep_num = 0
while self._sweeping:
sweep_num += 1
step_count = [0]
def progress_cb(freq, step_num, total, result):
step_count[0] = step_num + 1
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._update_progress, pct, freq, sweep_num)
self.app.call_from_thread(self._update_status, f"Sweeping #{sweep_num}...")
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell, sr_ksps=20000, callback=progress_cb,
)
self.app.call_from_thread(self._update_plot, freqs, powers, results, lnb_lo)
self.app.call_from_thread(self._update_waterfall, powers)
if not continuous:
break
time.sleep(0.1)
self._sweeping = False
self.app.call_from_thread(self._update_status, "Complete")
def _update_progress(self, pct: float, freq: float, sweep_num: int) -> None:
if not self.is_mounted:
return
self.query_one("#spec-pbar", ProgressBar).update(progress=pct)
self.query_one("#spec-status", Static).update(
f"[#00d4aa]Sweep #{sweep_num}[/] [#506878]{freq:.0f} MHz[/]"
)
def _update_status(self, msg: str) -> None:
if not self.is_mounted:
return
self.query_one("#spec-status", Static).update(f"[#506878]{msg}[/]")
self.query_one("#spec-pbar", ProgressBar).update(progress=0)
def _update_plot(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#spec-plot", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _update_waterfall(self, powers) -> None:
if not self.is_mounted:
return
self.query_one("#spec-waterfall", WaterfallDisplay).add_sweep(powers)
"""Spectrum screen — sweep analyzer across the IF range.
Displays a bar chart of power vs. frequency, optionally with a rolling
waterfall beneath it. Uses threaded workers for the blocking USB sweep.
"""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, ProgressBar, Checkbox
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.spectrum_plot import SpectrumPlot
from skywalker_tui.widgets.waterfall import WaterfallDisplay
class SpectrumScreen(Container):
"""Spectrum analyzer with bar chart and optional waterfall."""
DEFAULT_CSS = """
SpectrumScreen {
layout: vertical;
}
SpectrumScreen #spec-main {
height: 1fr;
layout: vertical;
}
SpectrumScreen #spec-progress-row {
height: 3;
layout: horizontal;
padding: 0 2;
background: #0e1018;
}
SpectrumScreen #spec-progress-row Static {
width: auto;
margin: 1 1 0 0;
}
SpectrumScreen #spec-progress-row ProgressBar {
width: 1fr;
margin: 1 1 0 0;
}
SpectrumScreen #spec-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
SpectrumScreen #spec-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
SpectrumScreen #spec-controls Input {
width: 10;
margin: 0 1;
}
SpectrumScreen #spec-controls Button {
margin: 0 1;
}
SpectrumScreen #spec-controls Checkbox {
margin: 1 1 0 0;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._sweeping = False
self._sweep_worker: Worker | None = None
def compose(self) -> ComposeResult:
with Vertical(id="spec-main"):
yield SpectrumPlot(title="Spectrum Analyzer", id="spec-plot")
yield WaterfallDisplay(title="Waterfall", id="spec-waterfall")
with Horizontal(id="spec-progress-row"):
yield Static("[#506878]Ready[/]", id="spec-status")
yield ProgressBar(total=100, show_eta=False, id="spec-pbar")
with Horizontal(id="spec-controls"):
yield Label("Start:")
yield Input("950", id="spec-start")
yield Label("Stop:")
yield Input("2150", id="spec-stop")
yield Label("Step:")
yield Input("5", id="spec-step")
yield Label("Dwell:")
yield Input("10", id="spec-dwell")
yield Label("LNB LO:")
yield Input("0", id="spec-lnb")
yield Checkbox("Continuous", id="spec-continuous")
yield Button("Sweep", id="spec-sweep-btn", variant="success")
yield Button("Stop", id="spec-stop-btn", variant="error")
def on_show(self) -> None:
if self._bridge.is_demo and not self._sweeping:
self._start_sweep()
def on_hide(self) -> None:
self._stop_sweep()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "spec-sweep-btn":
self._start_sweep()
elif event.button.id == "spec-stop-btn":
self._stop_sweep()
def _start_sweep(self) -> None:
if self._sweeping:
return
# Validate inputs
try:
start = float(self.query_one("#spec-start", Input).value or "950")
stop = float(self.query_one("#spec-stop", Input).value or "2150")
step = float(self.query_one("#spec-step", Input).value or "5")
dwell = int(float(self.query_one("#spec-dwell", Input).value or "10"))
lnb_lo = float(self.query_one("#spec-lnb", Input).value or "0")
except ValueError:
self._update_status("Invalid input — check numeric fields")
return
if not (950 <= start <= 2150) or not (950 <= stop <= 2150):
self._update_status("Frequency out of range (9502150 MHz)")
return
if start >= stop:
self._update_status("Start must be less than Stop")
return
if not (0.1 <= step <= 500):
self._update_status("Step out of range (0.1500 MHz)")
return
continuous = self.query_one("#spec-continuous", Checkbox).value
self._sweeping = True
self._sweep_worker = self._do_sweep(start, stop, step, dwell, lnb_lo, continuous)
def _stop_sweep(self) -> None:
self._sweeping = False
if self._sweep_worker:
self._sweep_worker.cancel()
self._sweep_worker = None
@work(thread=True)
def _do_sweep(self, start: float, stop: float, step: float,
dwell: int, lnb_lo: float, continuous: bool) -> None:
"""Background sweep worker."""
import time
try:
self._bridge.ensure_booted()
except Exception:
pass
sweep_num = 0
while self._sweeping:
sweep_num += 1
step_count = [0]
def progress_cb(freq, step_num, total, result):
step_count[0] = step_num + 1
pct = (step_num + 1) / total * 100
self.app.call_from_thread(self._update_progress, pct, freq, sweep_num)
self.app.call_from_thread(self._update_status, f"Sweeping #{sweep_num}...")
freqs, powers, results = self._bridge.sweep_spectrum(
start, stop, step, dwell, sr_ksps=20000, callback=progress_cb,
)
self.app.call_from_thread(self._update_plot, freqs, powers, results, lnb_lo)
self.app.call_from_thread(self._update_waterfall, powers)
if not continuous:
break
time.sleep(0.1)
self._sweeping = False
self.app.call_from_thread(self._update_status, "Complete")
def _update_progress(self, pct: float, freq: float, sweep_num: int) -> None:
if not self.is_mounted:
return
self.query_one("#spec-pbar", ProgressBar).update(progress=pct)
self.query_one("#spec-status", Static).update(
f"[#00d4aa]Sweep #{sweep_num}[/] [#506878]{freq:.0f} MHz[/]"
)
def _update_status(self, msg: str) -> None:
if not self.is_mounted:
return
self.query_one("#spec-status", Static).update(f"[#506878]{msg}[/]")
self.query_one("#spec-pbar", ProgressBar).update(progress=0)
def _update_plot(self, freqs, powers, results, lnb_lo) -> None:
if not self.is_mounted:
return
self.query_one("#spec-plot", SpectrumPlot).update_data(
freqs, powers, results, lnb_lo=lnb_lo,
)
def _update_waterfall(self, powers) -> None:
if not self.is_mounted:
return
self.query_one("#spec-waterfall", WaterfallDisplay).add_sweep(powers)

View file

@ -1,151 +1,151 @@
"""Splash screen — renders 16colo.rs art on startup.
Pre-baked ANSI half-block art (.ans files) display instantly no runtime
image decoding or terminal protocol detection needed. Generated by
scripts/prebake_splash.py from the original artwork.
Randomly selects from bundled artwork on each launch. Auto-dismisses
after 5 seconds or on any keypress.
"""
import os
import random
from pathlib import Path
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static
from rich.text import Text
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" / "splash"
# Artwork catalog — stem (sans extension), artist, title
ART_CATALOG = [
("seti-satellite", "Illarterate", "S.E.T.I. Satellite"),
("dialtone", "192.168.10.13", "Dialtone"),
("so-far-away", "Blippypixel", "So Far Away"),
("prodigy-out-of-space", "Jellica Jake", "Prodigy / Out of Space"),
("space-docker", "Blippypixel", "Space Docker"),
]
def _is_kitty() -> bool:
"""Detect if running inside Kitty terminal."""
return bool(os.environ.get("KITTY_WINDOW_ID"))
class SplashScreen(Screen):
"""Full-screen splash with 16colo.rs art, auto-dismisses."""
BINDINGS = [
Binding("escape", "dismiss_splash", "Skip", show=False),
]
DEFAULT_CSS = """
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-title {
height: 1;
color: #00d4aa;
text-style: bold;
text-align: center;
dock: bottom;
margin: 0 0 2 0;
}
SplashScreen #splash-credit {
height: 1;
color: #506878;
text-align: center;
dock: bottom;
margin: 0 0 1 0;
}
SplashScreen #splash-skip {
height: 1;
color: #2a3a4a;
text-align: center;
dock: bottom;
}
SplashScreen #splash-art {
width: 100%;
height: 1fr;
content-align: center middle;
background: #000000;
}
SplashScreen #splash-fallback {
width: 100%;
height: 1fr;
content-align: center middle;
color: #00d4aa;
text-style: bold;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ans_path: Path | None = None
self._artist = ""
self._title = ""
self._select_art()
def _select_art(self) -> None:
"""Pick a random artwork from available pre-baked .ans files."""
available = []
for stem, artist, title in ART_CATALOG:
ans_path = ASSETS_DIR / f"{stem}.ans"
if ans_path.exists():
available.append((ans_path, artist, title))
if available:
self._ans_path, self._artist, self._title = random.choice(available)
def compose(self) -> ComposeResult:
with Vertical(id="splash-container"):
if self._ans_path:
ansi_content = self._ans_path.read_text()
yield Static(Text.from_ansi(ansi_content), id="splash-art")
else:
yield Static(
"S K Y W A L K E R - 1",
id="splash-fallback",
)
kitty_tag = " \U0001f431" if _is_kitty() else ""
yield Static(
f"S K Y W A L K E R - 1 / DVB-S RF Tool{kitty_tag}",
id="splash-title",
)
if self._artist:
yield Static(
f"Art by {self._artist} \u2014 16colo.rs/mistigris",
id="splash-credit",
)
yield Static("[any key to skip]", id="splash-skip")
def on_mount(self) -> None:
# Brief delay before accepting key dismissal — prevents eating
# keystrokes from the transition that pushed this screen
self._accept_keys = False
self.set_timer(0.3, self._enable_keys)
self.set_timer(5, self.action_dismiss_splash)
def _enable_keys(self) -> None:
self._accept_keys = True
def on_key(self) -> None:
if getattr(self, "_accept_keys", False):
self.action_dismiss_splash()
def action_dismiss_splash(self) -> None:
if self.is_current:
self.app.pop_screen()
"""Splash screen — renders 16colo.rs art on startup.
Pre-baked ANSI half-block art (.ans files) display instantly no runtime
image decoding or terminal protocol detection needed. Generated by
scripts/prebake_splash.py from the original artwork.
Randomly selects from bundled artwork on each launch. Auto-dismisses
after 5 seconds or on any keypress.
"""
import os
import random
from pathlib import Path
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static
from rich.text import Text
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" / "splash"
# Artwork catalog — stem (sans extension), artist, title
ART_CATALOG = [
("seti-satellite", "Illarterate", "S.E.T.I. Satellite"),
("dialtone", "192.168.10.13", "Dialtone"),
("so-far-away", "Blippypixel", "So Far Away"),
("prodigy-out-of-space", "Jellica Jake", "Prodigy / Out of Space"),
("space-docker", "Blippypixel", "Space Docker"),
]
def _is_kitty() -> bool:
"""Detect if running inside Kitty terminal."""
return bool(os.environ.get("KITTY_WINDOW_ID"))
class SplashScreen(Screen):
"""Full-screen splash with 16colo.rs art, auto-dismisses."""
BINDINGS = [
Binding("escape", "dismiss_splash", "Skip", show=False),
]
DEFAULT_CSS = """
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-title {
height: 1;
color: #00d4aa;
text-style: bold;
text-align: center;
dock: bottom;
margin: 0 0 2 0;
}
SplashScreen #splash-credit {
height: 1;
color: #506878;
text-align: center;
dock: bottom;
margin: 0 0 1 0;
}
SplashScreen #splash-skip {
height: 1;
color: #2a3a4a;
text-align: center;
dock: bottom;
}
SplashScreen #splash-art {
width: 100%;
height: 1fr;
content-align: center middle;
background: #000000;
}
SplashScreen #splash-fallback {
width: 100%;
height: 1fr;
content-align: center middle;
color: #00d4aa;
text-style: bold;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._ans_path: Path | None = None
self._artist = ""
self._title = ""
self._select_art()
def _select_art(self) -> None:
"""Pick a random artwork from available pre-baked .ans files."""
available = []
for stem, artist, title in ART_CATALOG:
ans_path = ASSETS_DIR / f"{stem}.ans"
if ans_path.exists():
available.append((ans_path, artist, title))
if available:
self._ans_path, self._artist, self._title = random.choice(available)
def compose(self) -> ComposeResult:
with Vertical(id="splash-container"):
if self._ans_path:
ansi_content = self._ans_path.read_text()
yield Static(Text.from_ansi(ansi_content), id="splash-art")
else:
yield Static(
"S K Y W A L K E R - 1",
id="splash-fallback",
)
kitty_tag = " \U0001f431" if _is_kitty() else ""
yield Static(
f"S K Y W A L K E R - 1 / DVB-S RF Tool{kitty_tag}",
id="splash-title",
)
if self._artist:
yield Static(
f"Art by {self._artist} \u2014 16colo.rs/mistigris",
id="splash-credit",
)
yield Static("[any key to skip]", id="splash-skip")
def on_mount(self) -> None:
# Brief delay before accepting key dismissal — prevents eating
# keystrokes from the transition that pushed this screen
self._accept_keys = False
self.set_timer(0.3, self._enable_keys)
self.set_timer(5, self.action_dismiss_splash)
def _enable_keys(self) -> None:
self._accept_keys = True
def on_key(self) -> None:
if getattr(self, "_accept_keys", False):
self.action_dismiss_splash()
def action_dismiss_splash(self) -> None:
if self.is_current:
self.app.pop_screen()

View file

@ -1,448 +1,448 @@
"""Star Wars Easter egg — ASCII Star Wars via telnet with offline fallback.
Attempts to connect to towel.blinkenlights.nl:23 for the famous ASCII
Star Wars animation. If the network blocks port 23 (common), falls back
to a built-in opening crawl with ASCII art.
The telnet stream uses VT100 escape sequences:
ESC[H cursor home (frame boundary)
ESC[J clear to end of screen
ESC[...m SGR color/style codes
We detect ESC[H as "new frame", buffer the frame text, then render it
atomically via Rich's Text.from_ansi() which handles native ANSI styling.
Hidden binding: ctrl+w (W for Wars/Walker).
"""
import socket
import time
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static, RichLog
from textual import work
from rich.text import Text
TELNET_HOST = "towel.blinkenlights.nl"
TELNET_PORT = 23
RECV_SIZE = 4096
CONNECT_TIMEOUT = 8
# ── Built-in offline crawl ────────────────────────────────────────────
# Shown when telnet is unreachable. Frames are (lines, delay_seconds).
_CRAWL_FRAMES = [
# Pause on black
([""], 2.0),
# Title card
([
"",
"",
"",
" . . . . . . . . .",
"",
" A long time ago in a galaxy far,",
" far away....",
"",
"",
], 3.0),
# Clear + logo
([
"",
"",
r" ________________. ___ .______",
r" / | / \ | _ \ ",
r" | (-----| |----`/ ^ \ | |_) |",
r" \ \ | | / /_\ \ | /",
r" .-----) | | | / _____ \ | |\ \------.",
r" |________/ |__| /__/ \__\| _| `.________|",
"",
r" ____ __ ____ ___ .______ ________.",
r" \ \ / \ / / / \ | _ \ / |",
r" \ \/ \/ / / ^ \ | |_) || (-----`",
r" \ / / /_\ \ | / \ \ ",
r" \ /\ / / _____ \ | |\ \---) |",
r" \__/ \__/ /__/ \__\|__| `._______/",
"",
"",
], 4.0),
# Episode info
([
"",
"",
"",
" Episode IV.I",
"",
" A N E W F R E Q U E N C Y",
"",
"",
], 3.0),
# Opening crawl text — the SkyWalker-1 version
([
" It is a period of civil engineering.",
" Rebel hackers, armed with USB cables",
" and logic analyzers, have won their",
" first victory against the evil",
" Proprietary Firmware Empire.",
"",
" During the battle, rebel spies managed",
" to steal secret plans to the Empire's",
" ultimate weapon, the GP8PSK USB protocol,",
" a device with enough power to receive",
" an entire satellite transponder.",
"",
" Pursued by the Empire's sinister agents,",
" Princess SkyWalker races home aboard her",
" DVB-S receiver, custodian of the stolen",
" vendor commands that can save her people",
" and restore signal lock to the galaxy....",
"",
], 0.18), # per-line scroll for this frame
# Star Destroyer ASCII art
([
"",
"",
"",
" . ",
" /|\\ ",
" / | \\ ",
" / | \\ ",
" / | \\ ",
" ____/ | \\____ ",
" ____/ _______|_______ \\____ ",
" ____/ ____/ | \\____ \\____",
" _____/ ___/ | \\____\\_____ ",
" /______/=====_____________|_____________=====\\______\\",
" \\ \\============|============/ /",
" \\ \\ | / /",
" \\ |__________|__________| /",
" \\ | | | /",
" \\___|__________|__________|___/",
" \\ | /",
" \\_______/ \\_______/",
"",
"",
" >>> GENPIX SKYWALKER-1 DVB-S RECEIVER <<<",
" Firmware reversed. Signal acquired.",
"",
"",
], 3.5),
# Credits
([
"",
" ==========================================",
" Directed by .............. Ryan Malloy",
" Firmware by ........... Genpix Electronics",
" Reversed by ........... USB sniffers & gdb",
" Radar Scope by ........ P1 green phosphor",
" Theme Music by ........ 22 kHz tone burst",
"",
" ASCII Star Wars originally by:",
" Simon Jansen (asciimation.co.nz)",
" Sten Spans (blinkenlights.nl)",
" Mike Edwards (terminal tricks)",
"",
" Telnet blocked? Blame your ISP.",
" ==========================================",
"",
"",
" [ESC to close]",
"",
], 0),
]
class _TelnetStripper:
"""Stateful telnet IAC sequence stripper that handles chunk boundaries.
Telnet IAC sequences can span TCP segment boundaries. This class
buffers partial sequences across feed() calls so they're correctly
stripped even when split across recv() chunks.
"""
def __init__(self):
self._pending = b""
def feed(self, data: bytes) -> bytes:
data = self._pending + data
self._pending = b""
result = bytearray()
i = 0
while i < len(data):
if data[i] == 0xFF:
if i + 1 >= len(data):
self._pending = data[i:]
break
if data[i + 1] == 0xFF: # escaped 0xFF
result.append(0xFF)
i += 2
elif data[i + 1] in (0xFB, 0xFC, 0xFD, 0xFE): # WILL/WONT/DO/DONT
if i + 2 >= len(data):
self._pending = data[i:]
break
i += 3
elif data[i + 1] == 0xFA: # Sub-negotiation
end = data.find(b"\xff\xf0", i)
if end == -1:
self._pending = data[i:]
break
i = end + 2
else:
i += 2
else:
result.append(data[i])
i += 1
return bytes(result)
class StarWarsScreen(Screen):
"""Modal overlay streaming ASCII Star Wars from telnet, with offline fallback."""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("q", "dismiss", "Close", show=False),
]
DEFAULT_CSS = """
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
StarWarsScreen #sw-header {
height: 1;
color: #e8a020;
text-style: bold;
text-align: center;
padding: 0 1;
}
StarWarsScreen #sw-log {
height: 1fr;
background: #000000;
color: #c8d0d8;
}
StarWarsScreen #sw-footer {
height: 1;
color: #506878;
text-align: center;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._socket: socket.socket | None = None
self._running = False
def compose(self) -> ComposeResult:
with Vertical(id="sw-container"):
yield Static(
"A long time ago in a galaxy far, far away....",
id="sw-header",
)
yield RichLog(id="sw-log", wrap=False, markup=False)
yield Static("[ESC] Close", id="sw-footer")
def on_mount(self) -> None:
self._running = True
self._stream_starwars()
def on_unmount(self) -> None:
self._running = False
self._close_socket()
def action_dismiss(self) -> None:
self._running = False
self._close_socket()
self.app.pop_screen()
def _close_socket(self) -> None:
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
def _safe_write(self, text: str) -> None:
"""Write to RichLog only if screen is still mounted and running."""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.write(text)
except Exception:
pass
def _safe_clear(self) -> None:
"""Clear the RichLog."""
if not self._running or not self.is_mounted:
return
try:
self.query_one("#sw-log", RichLog).clear()
except Exception:
pass
@work(thread=True)
def _stream_starwars(self) -> None:
"""Try telnet first, fall back to built-in crawl."""
self.app.call_from_thread(
self._safe_write, "Connecting to towel.blinkenlights.nl:23..."
)
if self._try_telnet():
return # telnet worked, we're done
# Telnet failed — play the built-in crawl
self.app.call_from_thread(self._safe_write, "Falling back to local crawl...\n")
time.sleep(1.0)
self.app.call_from_thread(self._safe_clear)
self._play_offline_crawl()
def _try_telnet(self) -> bool:
"""Attempt telnet connection. Returns True if streaming completed.
Uses AF_UNSPEC to try both IPv6 and IPv4 (happy eyeballs style).
Many networks route IPv6 correctly but block or drop IPv4 on port 23.
"""
try:
addrs = socket.getaddrinfo(TELNET_HOST, TELNET_PORT,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addrs:
raise socket.gaierror("No address found")
labels = []
for fam, _, _, _, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
labels.append(f"{tag}:{sa[0]}")
self.app.call_from_thread(
self._safe_write, f"Resolved: {', '.join(labels)}"
)
except socket.gaierror as e:
self.app.call_from_thread(
self._safe_write, f"DNS failed: {e}"
)
return False
# Try each address until one connects (IPv6 first if available)
sock = None
last_err = None
for fam, socktype, proto, _canon, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
self.app.call_from_thread(
self._safe_write, f"Trying {tag}:{sa[0]}..."
)
try:
s = socket.socket(fam, socktype, proto)
s.settimeout(CONNECT_TIMEOUT)
s.connect(sa)
s.settimeout(2.0)
sock = s
break
except (socket.timeout, OSError) as e:
last_err = e
try:
s.close()
except Exception:
pass
if sock is None:
self.app.call_from_thread(
self._safe_write, f"All addresses failed: {last_err}"
)
return False
self._socket = sock
self.app.call_from_thread(self._safe_write, "Connected! Streaming...\n")
stripper = _TelnetStripper()
buffer = b""
while self._running:
try:
chunk = sock.recv(RECV_SIZE)
if not chunk:
break
clean = stripper.feed(chunk)
buffer += clean
# ESC[H (cursor home) marks frame boundaries.
# Buffer until we have a complete frame, then render atomically.
while b"\x1b[H" in buffer:
frame_data, buffer = buffer.split(b"\x1b[H", 1)
if frame_data.strip():
self.app.call_from_thread(
self._render_frame, frame_data
)
except socket.timeout:
# Timeout with no ESC[H — flush buffer as partial frame
if buffer.strip():
self.app.call_from_thread(
self._render_frame, buffer
)
buffer = b""
continue
except Exception:
break
# Flush anything remaining
if buffer.strip():
self.app.call_from_thread(self._render_frame, buffer)
self.app.call_from_thread(self._safe_write, "\n[Stream ended]")
self._close_socket()
return True
def _render_frame(self, data: bytes) -> None:
"""Render a complete animation frame, replacing previous content.
Uses Rich's Text.from_ansi() to natively handle any ANSI styling
in the stream. ESC[J (clear) is stripped since we replace the
whole frame anyway.
"""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.clear()
# Decode frame, strip ESC[J (redundant — we clear anyway)
text = data.replace(b"\x1b[J", b"")
text = text.decode("ascii", errors="replace")
text = text.replace("\r", "")
# Rich's from_ansi() renders ANSI color/style codes natively
log.write(Text.from_ansi(text))
except Exception:
pass
def _play_offline_crawl(self) -> None:
"""Play the built-in Star Wars opening crawl frame by frame."""
for lines, delay in _CRAWL_FRAMES:
if not self._running:
return
# The opening crawl frame scrolls line-by-line
if delay > 0 and delay < 0.5 and len(lines) > 3:
# Per-line scroll mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
time.sleep(delay)
else:
# Full-frame mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
if delay > 0:
time.sleep(delay)
"""Star Wars Easter egg — ASCII Star Wars via telnet with offline fallback.
Attempts to connect to towel.blinkenlights.nl:23 for the famous ASCII
Star Wars animation. If the network blocks port 23 (common), falls back
to a built-in opening crawl with ASCII art.
The telnet stream uses VT100 escape sequences:
ESC[H cursor home (frame boundary)
ESC[J clear to end of screen
ESC[...m SGR color/style codes
We detect ESC[H as "new frame", buffer the frame text, then render it
atomically via Rich's Text.from_ansi() which handles native ANSI styling.
Hidden binding: ctrl+w (W for Wars/Walker).
"""
import socket
import time
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.containers import Vertical
from textual.widgets import Static, RichLog
from textual import work
from rich.text import Text
TELNET_HOST = "towel.blinkenlights.nl"
TELNET_PORT = 23
RECV_SIZE = 4096
CONNECT_TIMEOUT = 8
# ── Built-in offline crawl ────────────────────────────────────────────
# Shown when telnet is unreachable. Frames are (lines, delay_seconds).
_CRAWL_FRAMES = [
# Pause on black
([""], 2.0),
# Title card
([
"",
"",
"",
" . . . . . . . . .",
"",
" A long time ago in a galaxy far,",
" far away....",
"",
"",
], 3.0),
# Clear + logo
([
"",
"",
r" ________________. ___ .______",
r" / | / \ | _ \ ",
r" | (-----| |----`/ ^ \ | |_) |",
r" \ \ | | / /_\ \ | /",
r" .-----) | | | / _____ \ | |\ \------.",
r" |________/ |__| /__/ \__\| _| `.________|",
"",
r" ____ __ ____ ___ .______ ________.",
r" \ \ / \ / / / \ | _ \ / |",
r" \ \/ \/ / / ^ \ | |_) || (-----`",
r" \ / / /_\ \ | / \ \ ",
r" \ /\ / / _____ \ | |\ \---) |",
r" \__/ \__/ /__/ \__\|__| `._______/",
"",
"",
], 4.0),
# Episode info
([
"",
"",
"",
" Episode IV.I",
"",
" A N E W F R E Q U E N C Y",
"",
"",
], 3.0),
# Opening crawl text — the SkyWalker-1 version
([
" It is a period of civil engineering.",
" Rebel hackers, armed with USB cables",
" and logic analyzers, have won their",
" first victory against the evil",
" Proprietary Firmware Empire.",
"",
" During the battle, rebel spies managed",
" to steal secret plans to the Empire's",
" ultimate weapon, the GP8PSK USB protocol,",
" a device with enough power to receive",
" an entire satellite transponder.",
"",
" Pursued by the Empire's sinister agents,",
" Princess SkyWalker races home aboard her",
" DVB-S receiver, custodian of the stolen",
" vendor commands that can save her people",
" and restore signal lock to the galaxy....",
"",
], 0.18), # per-line scroll for this frame
# Star Destroyer ASCII art
([
"",
"",
"",
" . ",
" /|\\ ",
" / | \\ ",
" / | \\ ",
" / | \\ ",
" ____/ | \\____ ",
" ____/ _______|_______ \\____ ",
" ____/ ____/ | \\____ \\____",
" _____/ ___/ | \\____\\_____ ",
" /______/=====_____________|_____________=====\\______\\",
" \\ \\============|============/ /",
" \\ \\ | / /",
" \\ |__________|__________| /",
" \\ | | | /",
" \\___|__________|__________|___/",
" \\ | /",
" \\_______/ \\_______/",
"",
"",
" >>> GENPIX SKYWALKER-1 DVB-S RECEIVER <<<",
" Firmware reversed. Signal acquired.",
"",
"",
], 3.5),
# Credits
([
"",
" ==========================================",
" Directed by .............. Ryan Malloy",
" Firmware by ........... Genpix Electronics",
" Reversed by ........... USB sniffers & gdb",
" Radar Scope by ........ P1 green phosphor",
" Theme Music by ........ 22 kHz tone burst",
"",
" ASCII Star Wars originally by:",
" Simon Jansen (asciimation.co.nz)",
" Sten Spans (blinkenlights.nl)",
" Mike Edwards (terminal tricks)",
"",
" Telnet blocked? Blame your ISP.",
" ==========================================",
"",
"",
" [ESC to close]",
"",
], 0),
]
class _TelnetStripper:
"""Stateful telnet IAC sequence stripper that handles chunk boundaries.
Telnet IAC sequences can span TCP segment boundaries. This class
buffers partial sequences across feed() calls so they're correctly
stripped even when split across recv() chunks.
"""
def __init__(self):
self._pending = b""
def feed(self, data: bytes) -> bytes:
data = self._pending + data
self._pending = b""
result = bytearray()
i = 0
while i < len(data):
if data[i] == 0xFF:
if i + 1 >= len(data):
self._pending = data[i:]
break
if data[i + 1] == 0xFF: # escaped 0xFF
result.append(0xFF)
i += 2
elif data[i + 1] in (0xFB, 0xFC, 0xFD, 0xFE): # WILL/WONT/DO/DONT
if i + 2 >= len(data):
self._pending = data[i:]
break
i += 3
elif data[i + 1] == 0xFA: # Sub-negotiation
end = data.find(b"\xff\xf0", i)
if end == -1:
self._pending = data[i:]
break
i = end + 2
else:
i += 2
else:
result.append(data[i])
i += 1
return bytes(result)
class StarWarsScreen(Screen):
"""Modal overlay streaming ASCII Star Wars from telnet, with offline fallback."""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("q", "dismiss", "Close", show=False),
]
DEFAULT_CSS = """
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
StarWarsScreen #sw-header {
height: 1;
color: #e8a020;
text-style: bold;
text-align: center;
padding: 0 1;
}
StarWarsScreen #sw-log {
height: 1fr;
background: #000000;
color: #c8d0d8;
}
StarWarsScreen #sw-footer {
height: 1;
color: #506878;
text-align: center;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._socket: socket.socket | None = None
self._running = False
def compose(self) -> ComposeResult:
with Vertical(id="sw-container"):
yield Static(
"A long time ago in a galaxy far, far away....",
id="sw-header",
)
yield RichLog(id="sw-log", wrap=False, markup=False)
yield Static("[ESC] Close", id="sw-footer")
def on_mount(self) -> None:
self._running = True
self._stream_starwars()
def on_unmount(self) -> None:
self._running = False
self._close_socket()
def action_dismiss(self) -> None:
self._running = False
self._close_socket()
self.app.pop_screen()
def _close_socket(self) -> None:
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
def _safe_write(self, text: str) -> None:
"""Write to RichLog only if screen is still mounted and running."""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.write(text)
except Exception:
pass
def _safe_clear(self) -> None:
"""Clear the RichLog."""
if not self._running or not self.is_mounted:
return
try:
self.query_one("#sw-log", RichLog).clear()
except Exception:
pass
@work(thread=True)
def _stream_starwars(self) -> None:
"""Try telnet first, fall back to built-in crawl."""
self.app.call_from_thread(
self._safe_write, "Connecting to towel.blinkenlights.nl:23..."
)
if self._try_telnet():
return # telnet worked, we're done
# Telnet failed — play the built-in crawl
self.app.call_from_thread(self._safe_write, "Falling back to local crawl...\n")
time.sleep(1.0)
self.app.call_from_thread(self._safe_clear)
self._play_offline_crawl()
def _try_telnet(self) -> bool:
"""Attempt telnet connection. Returns True if streaming completed.
Uses AF_UNSPEC to try both IPv6 and IPv4 (happy eyeballs style).
Many networks route IPv6 correctly but block or drop IPv4 on port 23.
"""
try:
addrs = socket.getaddrinfo(TELNET_HOST, TELNET_PORT,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addrs:
raise socket.gaierror("No address found")
labels = []
for fam, _, _, _, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
labels.append(f"{tag}:{sa[0]}")
self.app.call_from_thread(
self._safe_write, f"Resolved: {', '.join(labels)}"
)
except socket.gaierror as e:
self.app.call_from_thread(
self._safe_write, f"DNS failed: {e}"
)
return False
# Try each address until one connects (IPv6 first if available)
sock = None
last_err = None
for fam, socktype, proto, _canon, sa in addrs:
tag = "v6" if fam == socket.AF_INET6 else "v4"
self.app.call_from_thread(
self._safe_write, f"Trying {tag}:{sa[0]}..."
)
try:
s = socket.socket(fam, socktype, proto)
s.settimeout(CONNECT_TIMEOUT)
s.connect(sa)
s.settimeout(2.0)
sock = s
break
except (socket.timeout, OSError) as e:
last_err = e
try:
s.close()
except Exception:
pass
if sock is None:
self.app.call_from_thread(
self._safe_write, f"All addresses failed: {last_err}"
)
return False
self._socket = sock
self.app.call_from_thread(self._safe_write, "Connected! Streaming...\n")
stripper = _TelnetStripper()
buffer = b""
while self._running:
try:
chunk = sock.recv(RECV_SIZE)
if not chunk:
break
clean = stripper.feed(chunk)
buffer += clean
# ESC[H (cursor home) marks frame boundaries.
# Buffer until we have a complete frame, then render atomically.
while b"\x1b[H" in buffer:
frame_data, buffer = buffer.split(b"\x1b[H", 1)
if frame_data.strip():
self.app.call_from_thread(
self._render_frame, frame_data
)
except socket.timeout:
# Timeout with no ESC[H — flush buffer as partial frame
if buffer.strip():
self.app.call_from_thread(
self._render_frame, buffer
)
buffer = b""
continue
except Exception:
break
# Flush anything remaining
if buffer.strip():
self.app.call_from_thread(self._render_frame, buffer)
self.app.call_from_thread(self._safe_write, "\n[Stream ended]")
self._close_socket()
return True
def _render_frame(self, data: bytes) -> None:
"""Render a complete animation frame, replacing previous content.
Uses Rich's Text.from_ansi() to natively handle any ANSI styling
in the stream. ESC[J (clear) is stripped since we replace the
whole frame anyway.
"""
if not self._running or not self.is_mounted:
return
try:
log = self.query_one("#sw-log", RichLog)
log.clear()
# Decode frame, strip ESC[J (redundant — we clear anyway)
text = data.replace(b"\x1b[J", b"")
text = text.decode("ascii", errors="replace")
text = text.replace("\r", "")
# Rich's from_ansi() renders ANSI color/style codes natively
log.write(Text.from_ansi(text))
except Exception:
pass
def _play_offline_crawl(self) -> None:
"""Play the built-in Star Wars opening crawl frame by frame."""
for lines, delay in _CRAWL_FRAMES:
if not self._running:
return
# The opening crawl frame scrolls line-by-line
if delay > 0 and delay < 0.5 and len(lines) > 3:
# Per-line scroll mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
time.sleep(delay)
else:
# Full-frame mode
self.app.call_from_thread(self._safe_clear)
for line in lines:
if not self._running:
return
self.app.call_from_thread(self._safe_write, line)
if delay > 0:
time.sleep(delay)

View file

@ -1,401 +1,401 @@
"""Stream screen -- live MPEG-2 transport stream capture and analysis.
Reads raw TS data from the SkyWalker-1 bulk endpoint, parses 188-byte
packets in real time, and displays PID distribution statistics alongside
a hierarchical PSI (PAT/PMT) program structure tree.
Supports file capture mode for saving raw .ts files to disk.
arm_transfer(on) is always paired in try/finally to guarantee the USB
bulk endpoint is disarmed when monitoring stops.
"""
import time
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from ts_analyze import (
TSPacket, PSIParser, parse_pat, parse_pmt,
KNOWN_PIDS, TS_PACKET_SIZE,
)
from skywalker_tui.widgets.pid_table import PidTable
from skywalker_tui.widgets.psi_tree import PsiTree
class StreamScreen(Container):
"""Live MPEG-2 TS monitor with PID analysis and PSI tree."""
DEFAULT_CSS = """
StreamScreen {
layout: vertical;
}
StreamScreen #stream-main {
height: 1fr;
layout: horizontal;
}
StreamScreen #stream-pid-col {
width: 3fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-psi-col {
width: 2fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
StreamScreen #stream-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
StreamScreen #stream-controls Input {
width: 14;
margin: 0 1;
}
StreamScreen #stream-controls Button {
margin: 0 1;
}
StreamScreen #stream-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
StreamScreen #stream-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._monitoring = False
self._capturing = False
self._capture_file = None
self._monitor_worker: Worker | None = None
# Accumulated stats (written by worker thread, read by UI thread)
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts: dict[int, int] = {}
self._cc_last: dict[int, int] = {}
self._cc_errors: dict[int, int] = {}
self._start_time = 0.0
def compose(self) -> ComposeResult:
with Horizontal(id="stream-main"):
with Vertical(id="stream-pid-col"):
yield Static(
"[bold #00d4aa]PID Distribution[/]", classes="panel-title"
)
yield PidTable(id="stream-pid-table")
with Vertical(id="stream-psi-col"):
yield Static(
"[bold #00d4aa]Program Structure[/]", classes="panel-title"
)
yield PsiTree(id="stream-psi-tree")
with Horizontal(id="stream-stats"):
yield Static("[#506878]Packets:[/] [#00d4aa]0[/]", id="stat-packets")
yield Static("[#506878]Bytes:[/] [#00d4aa]0[/]", id="stat-bytes")
yield Static("[#506878]PIDs:[/] [#00d4aa]0[/]", id="stat-pids")
yield Static("[#506878]CC Errors:[/] [#00d4aa]0[/]", id="stat-cc")
yield Static(
"[#506878]Duration:[/] [#00d4aa]0.0s[/]", id="stat-duration"
)
with Horizontal(id="stream-controls"):
yield Button("Start Monitor", id="stream-start", variant="success")
yield Button("Stop", id="stream-stop", variant="error")
yield Label("Capture:")
yield Input("capture.ts", id="stream-capture-file")
yield Button("Capture", id="stream-capture", variant="warning")
yield Static("[#506878]Idle[/]", id="stream-status")
def on_show(self) -> None:
"""Auto-start monitoring in demo mode when this screen becomes visible."""
if self._bridge.is_demo and not self._monitoring:
self._start_monitor()
def on_hide(self) -> None:
"""Stop monitoring when navigating away from this screen."""
self._stop_monitor()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "stream-start":
self._start_monitor()
elif event.button.id == "stream-stop":
self._stop_monitor()
elif event.button.id == "stream-capture":
self._toggle_capture()
# -- Monitor lifecycle --
def _start_monitor(self) -> None:
"""Begin streaming from the device bulk endpoint."""
if self._monitoring:
return
self._monitoring = True
self._reset_stats()
self._start_time = time.monotonic()
try:
self.query_one("#stream-status", Static).update(
"[bold #00d4aa]Monitoring[/]"
)
except Exception:
pass
self._monitor_worker = self._do_monitor()
def _stop_monitor(self) -> None:
"""Stop the monitor worker and clean up capture state."""
self._monitoring = False
self._capturing = False
# Cancel worker BEFORE closing file — worker may still be writing
if self._monitor_worker is not None:
self._monitor_worker.cancel()
self._monitor_worker = None
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-status", Static).update(
"[#506878]Stopped[/]"
)
except Exception:
pass
def _toggle_capture(self) -> None:
"""Toggle file capture on/off. Starts monitor if not already running."""
if self._capturing:
self._capturing = False
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-capture", Button).label = "Capture"
except Exception:
pass
return
# Start capture
if not self._monitoring:
self._start_monitor()
filename = self.query_one("#stream-capture-file", Input).value or "capture.ts"
try:
self._capture_file = open(filename, "wb")
self._capturing = True
self.query_one("#stream-capture", Button).label = "Stop Capture"
except OSError as e:
self.app.notify(f"Cannot open {filename}: {e}", severity="error")
def _reset_stats(self) -> None:
"""Zero all counters and clear display widgets."""
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts.clear()
self._cc_last.clear()
self._cc_errors.clear()
try:
self.query_one("#stream-pid-table", PidTable).clear_table()
self.query_one("#stream-psi-tree", PsiTree).clear_tree()
except Exception:
pass
# -- Background worker --
@work(thread=True)
def _do_monitor(self) -> None:
"""Background worker: read TS stream, parse packets, post UI updates.
Runs in a dedicated thread via Textual's @work(thread=True) decorator.
Calls arm_transfer(True) on entry and arm_transfer(False) in finally
to guarantee the bulk endpoint is always disarmed on exit.
"""
psi_pat = PSIParser()
psi_pmt = PSIParser()
pat = None
pmt_pids: set[int] = set()
last_ui_update = 0.0
try:
self._bridge.ensure_booted()
self._bridge.arm_transfer(True)
while self._monitoring:
data = self._bridge.read_stream(8192, timeout=1000)
if not data:
time.sleep(0.05)
continue
self._total_bytes += len(data)
# Write raw data to capture file if active
if self._capturing and self._capture_file is not None:
try:
self._capture_file.write(data)
except OSError:
self._capturing = False
# Parse 188-byte TS packets from the chunk
offset = 0
while offset + TS_PACKET_SIZE <= len(data):
if data[offset] != 0x47:
# Lost sync, scan forward for next sync byte
offset += 1
continue
try:
pkt = TSPacket(data[offset:offset + TS_PACKET_SIZE])
except (ValueError, IndexError):
offset += 1
continue
offset += TS_PACKET_SIZE
self._total_packets += 1
pid = pkt.pid
# PID counting
self._pid_counts[pid] = self._pid_counts.get(pid, 0) + 1
# TEI (Transport Error Indicator) check
if pkt.tei:
self._tei_count += 1
# Continuity counter check (payload-bearing, non-null PIDs)
if pkt.adaptation & 0x01 and pid != 0x1FFF:
if pid in self._cc_last:
expected = (self._cc_last[pid] + 1) & 0x0F
if (pkt.continuity != expected
and pkt.continuity != self._cc_last[pid]):
self._cc_errors[pid] = (
self._cc_errors.get(pid, 0) + 1
)
self._cc_last[pid] = pkt.continuity
# PAT parsing (PID 0x0000)
if pid == 0x0000:
section = psi_pat.feed(pkt)
if section is not None:
parsed = parse_pat(section)
if parsed is not None:
pat = parsed
for prog_num, pmt_pid in pat.get(
"programs", {}
).items():
if prog_num != 0:
pmt_pids.add(pmt_pid)
# PMT parsing (PIDs discovered from PAT)
if pid in pmt_pids:
section = psi_pmt.feed(pkt)
if section is not None:
parsed = parse_pmt(section)
if parsed is not None:
self.app.call_from_thread(
self._update_pmt, pid, parsed
)
# Batch UI updates every 500ms to avoid flooding the event loop
now = time.monotonic()
if now - last_ui_update >= 0.5:
last_ui_update = now
self.app.call_from_thread(
self._update_ui,
pat,
dict(self._pid_counts),
dict(self._cc_errors),
self._total_packets,
self._total_bytes,
self._tei_count,
)
finally:
for _attempt in range(2):
try:
self._bridge.arm_transfer(False)
break
except Exception:
time.sleep(0.1)
# -- UI update methods (called from main thread) --
def _update_ui(
self,
pat: dict | None,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total_pkts: int,
total_bytes: int,
tei_count: int,
) -> None:
"""Push accumulated stats to display widgets."""
if not self.is_mounted:
return
# Build known PID names by merging standard table with PAT-discovered PMTs
known = dict(KNOWN_PIDS)
if pat:
for prog_num, pmt_pid in pat.get("programs", {}).items():
if prog_num == 0:
known[pmt_pid] = "NIT"
else:
known[pmt_pid] = f"PMT (prog {prog_num})"
self.query_one("#stream-pid-table", PidTable).update_pids(
pid_counts, cc_errors, total_pkts, known
)
if pat:
self.query_one("#stream-psi-tree", PsiTree).update_pat(pat)
total_cc = sum(cc_errors.values())
duration = time.monotonic() - self._start_time
self.query_one("#stat-packets", Static).update(
f"[#506878]Packets:[/] [#00d4aa]{total_pkts:,}[/]"
)
if total_bytes >= 1_000_000:
bytes_str = f"{total_bytes / 1_000_000:.1f} MB"
elif total_bytes >= 1_000:
bytes_str = f"{total_bytes / 1_000:.1f} KB"
else:
bytes_str = str(total_bytes)
self.query_one("#stat-bytes", Static).update(
f"[#506878]Bytes:[/] [#00d4aa]{bytes_str}[/]"
)
self.query_one("#stat-pids", Static).update(
f"[#506878]PIDs:[/] [#00d4aa]{len(pid_counts)}[/]"
)
cc_color = "#e04040" if total_cc > 0 else "#00d4aa"
self.query_one("#stat-cc", Static).update(
f"[#506878]CC Errors:[/] [{cc_color}]{total_cc}[/]"
)
self.query_one("#stat-duration", Static).update(
f"[#506878]Duration:[/] [#00d4aa]{duration:.1f}s[/]"
)
def _update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Push a newly parsed PMT to the PSI tree widget."""
if not self.is_mounted:
return
self.query_one("#stream-psi-tree", PsiTree).update_pmt(pmt_pid, pmt)
"""Stream screen -- live MPEG-2 transport stream capture and analysis.
Reads raw TS data from the SkyWalker-1 bulk endpoint, parses 188-byte
packets in real time, and displays PID distribution statistics alongside
a hierarchical PSI (PAT/PMT) program structure tree.
Supports file capture mode for saving raw .ts files to disk.
arm_transfer(on) is always paired in try/finally to guarantee the USB
bulk endpoint is disarmed when monitoring stops.
"""
import time
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static
from textual import work
from textual.worker import Worker
from ts_analyze import (
TSPacket, PSIParser, parse_pat, parse_pmt,
KNOWN_PIDS, TS_PACKET_SIZE,
)
from skywalker_tui.widgets.pid_table import PidTable
from skywalker_tui.widgets.psi_tree import PsiTree
class StreamScreen(Container):
"""Live MPEG-2 TS monitor with PID analysis and PSI tree."""
DEFAULT_CSS = """
StreamScreen {
layout: vertical;
}
StreamScreen #stream-main {
height: 1fr;
layout: horizontal;
}
StreamScreen #stream-pid-col {
width: 3fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-psi-col {
width: 2fr;
height: 1fr;
padding: 1;
}
StreamScreen #stream-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
StreamScreen #stream-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
StreamScreen #stream-controls Input {
width: 14;
margin: 0 1;
}
StreamScreen #stream-controls Button {
margin: 0 1;
}
StreamScreen #stream-stats {
height: 3;
layout: horizontal;
padding: 0 2;
}
StreamScreen #stream-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._monitoring = False
self._capturing = False
self._capture_file = None
self._monitor_worker: Worker | None = None
# Accumulated stats (written by worker thread, read by UI thread)
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts: dict[int, int] = {}
self._cc_last: dict[int, int] = {}
self._cc_errors: dict[int, int] = {}
self._start_time = 0.0
def compose(self) -> ComposeResult:
with Horizontal(id="stream-main"):
with Vertical(id="stream-pid-col"):
yield Static(
"[bold #00d4aa]PID Distribution[/]", classes="panel-title"
)
yield PidTable(id="stream-pid-table")
with Vertical(id="stream-psi-col"):
yield Static(
"[bold #00d4aa]Program Structure[/]", classes="panel-title"
)
yield PsiTree(id="stream-psi-tree")
with Horizontal(id="stream-stats"):
yield Static("[#506878]Packets:[/] [#00d4aa]0[/]", id="stat-packets")
yield Static("[#506878]Bytes:[/] [#00d4aa]0[/]", id="stat-bytes")
yield Static("[#506878]PIDs:[/] [#00d4aa]0[/]", id="stat-pids")
yield Static("[#506878]CC Errors:[/] [#00d4aa]0[/]", id="stat-cc")
yield Static(
"[#506878]Duration:[/] [#00d4aa]0.0s[/]", id="stat-duration"
)
with Horizontal(id="stream-controls"):
yield Button("Start Monitor", id="stream-start", variant="success")
yield Button("Stop", id="stream-stop", variant="error")
yield Label("Capture:")
yield Input("capture.ts", id="stream-capture-file")
yield Button("Capture", id="stream-capture", variant="warning")
yield Static("[#506878]Idle[/]", id="stream-status")
def on_show(self) -> None:
"""Auto-start monitoring in demo mode when this screen becomes visible."""
if self._bridge.is_demo and not self._monitoring:
self._start_monitor()
def on_hide(self) -> None:
"""Stop monitoring when navigating away from this screen."""
self._stop_monitor()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "stream-start":
self._start_monitor()
elif event.button.id == "stream-stop":
self._stop_monitor()
elif event.button.id == "stream-capture":
self._toggle_capture()
# -- Monitor lifecycle --
def _start_monitor(self) -> None:
"""Begin streaming from the device bulk endpoint."""
if self._monitoring:
return
self._monitoring = True
self._reset_stats()
self._start_time = time.monotonic()
try:
self.query_one("#stream-status", Static).update(
"[bold #00d4aa]Monitoring[/]"
)
except Exception:
pass
self._monitor_worker = self._do_monitor()
def _stop_monitor(self) -> None:
"""Stop the monitor worker and clean up capture state."""
self._monitoring = False
self._capturing = False
# Cancel worker BEFORE closing file — worker may still be writing
if self._monitor_worker is not None:
self._monitor_worker.cancel()
self._monitor_worker = None
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-status", Static).update(
"[#506878]Stopped[/]"
)
except Exception:
pass
def _toggle_capture(self) -> None:
"""Toggle file capture on/off. Starts monitor if not already running."""
if self._capturing:
self._capturing = False
if self._capture_file is not None:
self._capture_file.close()
self._capture_file = None
try:
self.query_one("#stream-capture", Button).label = "Capture"
except Exception:
pass
return
# Start capture
if not self._monitoring:
self._start_monitor()
filename = self.query_one("#stream-capture-file", Input).value or "capture.ts"
try:
self._capture_file = open(filename, "wb")
self._capturing = True
self.query_one("#stream-capture", Button).label = "Stop Capture"
except OSError as e:
self.app.notify(f"Cannot open {filename}: {e}", severity="error")
def _reset_stats(self) -> None:
"""Zero all counters and clear display widgets."""
self._total_packets = 0
self._total_bytes = 0
self._tei_count = 0
self._pid_counts.clear()
self._cc_last.clear()
self._cc_errors.clear()
try:
self.query_one("#stream-pid-table", PidTable).clear_table()
self.query_one("#stream-psi-tree", PsiTree).clear_tree()
except Exception:
pass
# -- Background worker --
@work(thread=True)
def _do_monitor(self) -> None:
"""Background worker: read TS stream, parse packets, post UI updates.
Runs in a dedicated thread via Textual's @work(thread=True) decorator.
Calls arm_transfer(True) on entry and arm_transfer(False) in finally
to guarantee the bulk endpoint is always disarmed on exit.
"""
psi_pat = PSIParser()
psi_pmt = PSIParser()
pat = None
pmt_pids: set[int] = set()
last_ui_update = 0.0
try:
self._bridge.ensure_booted()
self._bridge.arm_transfer(True)
while self._monitoring:
data = self._bridge.read_stream(8192, timeout=1000)
if not data:
time.sleep(0.05)
continue
self._total_bytes += len(data)
# Write raw data to capture file if active
if self._capturing and self._capture_file is not None:
try:
self._capture_file.write(data)
except OSError:
self._capturing = False
# Parse 188-byte TS packets from the chunk
offset = 0
while offset + TS_PACKET_SIZE <= len(data):
if data[offset] != 0x47:
# Lost sync, scan forward for next sync byte
offset += 1
continue
try:
pkt = TSPacket(data[offset:offset + TS_PACKET_SIZE])
except (ValueError, IndexError):
offset += 1
continue
offset += TS_PACKET_SIZE
self._total_packets += 1
pid = pkt.pid
# PID counting
self._pid_counts[pid] = self._pid_counts.get(pid, 0) + 1
# TEI (Transport Error Indicator) check
if pkt.tei:
self._tei_count += 1
# Continuity counter check (payload-bearing, non-null PIDs)
if pkt.adaptation & 0x01 and pid != 0x1FFF:
if pid in self._cc_last:
expected = (self._cc_last[pid] + 1) & 0x0F
if (pkt.continuity != expected
and pkt.continuity != self._cc_last[pid]):
self._cc_errors[pid] = (
self._cc_errors.get(pid, 0) + 1
)
self._cc_last[pid] = pkt.continuity
# PAT parsing (PID 0x0000)
if pid == 0x0000:
section = psi_pat.feed(pkt)
if section is not None:
parsed = parse_pat(section)
if parsed is not None:
pat = parsed
for prog_num, pmt_pid in pat.get(
"programs", {}
).items():
if prog_num != 0:
pmt_pids.add(pmt_pid)
# PMT parsing (PIDs discovered from PAT)
if pid in pmt_pids:
section = psi_pmt.feed(pkt)
if section is not None:
parsed = parse_pmt(section)
if parsed is not None:
self.app.call_from_thread(
self._update_pmt, pid, parsed
)
# Batch UI updates every 500ms to avoid flooding the event loop
now = time.monotonic()
if now - last_ui_update >= 0.5:
last_ui_update = now
self.app.call_from_thread(
self._update_ui,
pat,
dict(self._pid_counts),
dict(self._cc_errors),
self._total_packets,
self._total_bytes,
self._tei_count,
)
finally:
for _attempt in range(2):
try:
self._bridge.arm_transfer(False)
break
except Exception:
time.sleep(0.1)
# -- UI update methods (called from main thread) --
def _update_ui(
self,
pat: dict | None,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total_pkts: int,
total_bytes: int,
tei_count: int,
) -> None:
"""Push accumulated stats to display widgets."""
if not self.is_mounted:
return
# Build known PID names by merging standard table with PAT-discovered PMTs
known = dict(KNOWN_PIDS)
if pat:
for prog_num, pmt_pid in pat.get("programs", {}).items():
if prog_num == 0:
known[pmt_pid] = "NIT"
else:
known[pmt_pid] = f"PMT (prog {prog_num})"
self.query_one("#stream-pid-table", PidTable).update_pids(
pid_counts, cc_errors, total_pkts, known
)
if pat:
self.query_one("#stream-psi-tree", PsiTree).update_pat(pat)
total_cc = sum(cc_errors.values())
duration = time.monotonic() - self._start_time
self.query_one("#stat-packets", Static).update(
f"[#506878]Packets:[/] [#00d4aa]{total_pkts:,}[/]"
)
if total_bytes >= 1_000_000:
bytes_str = f"{total_bytes / 1_000_000:.1f} MB"
elif total_bytes >= 1_000:
bytes_str = f"{total_bytes / 1_000:.1f} KB"
else:
bytes_str = str(total_bytes)
self.query_one("#stat-bytes", Static).update(
f"[#506878]Bytes:[/] [#00d4aa]{bytes_str}[/]"
)
self.query_one("#stat-pids", Static).update(
f"[#506878]PIDs:[/] [#00d4aa]{len(pid_counts)}[/]"
)
cc_color = "#e04040" if total_cc > 0 else "#00d4aa"
self.query_one("#stat-cc", Static).update(
f"[#506878]CC Errors:[/] [{cc_color}]{total_cc}[/]"
)
self.query_one("#stat-duration", Static).update(
f"[#506878]Duration:[/] [#00d4aa]{duration:.1f}s[/]"
)
def _update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Push a newly parsed PMT to the PSI tree widget."""
if not self.is_mounted:
return
self.query_one("#stream-psi-tree", PsiTree).update_pmt(pmt_pid, pmt)

File diff suppressed because it is too large Load diff

View file

@ -1,392 +1,392 @@
"""Track screen — carrier/beacon tracker with radar scope, logging, and export.
Locks to a single frequency and records SNR, power, lock state over time.
Features a hero radar scope (P1 phosphor CRT aesthetic), dual sparklines,
event log for lock transitions, and stats. Supports CSV/JSONL export.
"""
import csv
import json
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, RichLog
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.radar_scope import RadarScope
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class TrackScreen(Container):
"""Long-running carrier tracker with radar scope and event log."""
DEFAULT_CSS = """
TrackScreen {
layout: vertical;
}
TrackScreen #track-main {
height: 1fr;
layout: horizontal;
padding: 1 2;
}
TrackScreen #track-radar-col {
width: 1fr;
min-width: 30;
layout: vertical;
}
TrackScreen #track-radar-label {
height: 1;
color: #0a5a10;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-data-col {
width: 1fr;
layout: vertical;
padding: 0 0 0 1;
}
TrackScreen #track-sparklines {
height: auto;
layout: vertical;
}
TrackScreen #track-log-container {
height: 10;
background: #0e1018;
border: round #1a2a3a;
margin: 1 0;
}
TrackScreen #track-log-title {
height: 1;
color: #00d4aa;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-log {
height: 1fr;
}
TrackScreen #track-stats {
height: 3;
layout: horizontal;
}
TrackScreen #track-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
TrackScreen #track-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
TrackScreen #track-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
TrackScreen #track-controls Input {
width: 14;
margin: 0 1;
}
TrackScreen #track-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._tracking = False
self._track_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
self._start_time = 0.0
self._was_locked: bool | None = None
self._records: deque[dict] = deque(maxlen=360_000) # ~10h at 10Hz
def compose(self) -> ComposeResult:
with Horizontal(id="track-main"):
with Vertical(id="track-radar-col"):
yield Static("[#0a5a10 bold]Radar Scope[/]", id="track-radar-label")
yield RadarScope(id="track-radar")
with Vertical(id="track-data-col"):
with Vertical(id="track-sparklines"):
yield SparklineWidget(title="SNR (dB)", color="#00d4aa",
id="track-snr-spark")
yield SparklineWidget(title="Power (dB)", color="#2196f3",
id="track-power-spark")
with Vertical(id="track-log-container"):
yield Static("[#00d4aa bold]Event Log[/]", id="track-log-title")
yield RichLog(id="track-log", wrap=True, markup=True)
with Horizontal(id="track-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="trk-samples")
yield Static("[#506878]Elapsed:[/] [#00d4aa]0s[/]", id="trk-elapsed")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="trk-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="trk-status")
with Horizontal(id="track-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="trk-freq")
yield Label("SR (ksps):")
yield Input("20000", id="trk-sr")
yield Label("Rate (Hz):")
yield Input("1", id="trk-rate")
yield Button("Start", id="trk-start-btn", variant="success")
yield Button("Stop", id="trk-stop-btn", variant="error")
yield Button("Export CSV", id="trk-csv-btn")
yield Button("Export JSONL", id="trk-jsonl-btn")
def on_show(self) -> None:
if self._bridge.is_demo and not self._tracking:
self._start_tracking()
def on_hide(self) -> None:
self._stop_tracking()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "trk-start-btn":
self._start_tracking()
elif event.button.id == "trk-stop-btn":
self._stop_tracking()
elif event.button.id == "trk-csv-btn":
self._export_csv()
elif event.button.id == "trk-jsonl-btn":
self._export_jsonl()
def _start_tracking(self) -> None:
if self._tracking:
return
log = self.query_one("#track-log", RichLog)
# C3: Validate inputs before starting
try:
freq = float(self.query_one("#trk-freq", Input).value or "1200")
except ValueError:
log.write("[bold #e04040]Invalid frequency — must be a number[/]")
return
try:
sr = int(float(self.query_one("#trk-sr", Input).value or "20000"))
except ValueError:
log.write("[bold #e04040]Invalid symbol rate — must be a number[/]")
return
try:
rate = float(self.query_one("#trk-rate", Input).value or "1")
except ValueError:
log.write("[bold #e04040]Invalid rate — must be a number[/]")
return
if not (950 <= freq <= 2150):
log.write("[bold #e04040]Frequency out of range (9502150 MHz)[/]")
return
if not (256 <= sr <= 30000):
log.write("[bold #e04040]Symbol rate out of range (25630000 ksps)[/]")
return
if not (0.1 <= rate <= 100):
log.write("[bold #e04040]Rate out of range (0.1100 Hz)[/]")
return
self._tracking = True
self._sample_count = 0
self._peak_snr = 0.0
self._was_locked = None
self._records.clear()
self._start_time = time.monotonic()
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Tracking[/]"
)
log.clear()
log.write("[#506878]Tracking started[/]")
self._track_worker = self._do_track(freq, sr, rate)
def _stop_tracking(self) -> None:
self._tracking = False
if self._track_worker:
self._track_worker.cancel()
self._track_worker = None
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
log = self.query_one("#track-log", RichLog)
log.write(f"[#506878]Stopped. {self._sample_count} samples.[/]")
except Exception:
pass
@work(thread=True)
def _do_track(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background tracking loop with circuit breaker."""
max_consecutive_errors = 10
interval = 1.0 / max(0.1, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception as e:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Boot/tune failed:[/] [#e04040]{e}[/]",
)
consecutive_errors = 0
while self._tracking:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
consecutive_errors = 0 # reset on success
except Exception:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Circuit breaker: "
f"{max_consecutive_errors} consecutive errors, stopping[/]",
)
self._tracking = False
self.app.call_from_thread(self._mark_stopped)
return
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
self._peak_snr = max(self._peak_snr, snr_db)
elapsed = time.monotonic() - self._start_time
record = {
"ts": datetime.now().isoformat(),
"elapsed": round(elapsed, 3),
"snr_db": round(snr_db, 2),
"agc1": sig.get("agc1", 0),
"agc2": sig.get("agc2", 0),
"power_db": round(sig.get("power_db", -40), 2),
"locked": locked,
}
self._records.append(record)
# Lock transition detection
lock_event = None
if self._was_locked is not None and locked != self._was_locked:
if locked:
lock_event = ("lock", snr_db)
else:
lock_event = ("unlock", snr_db)
self._was_locked = locked
self.app.call_from_thread(
self._update_ui, sig, elapsed, lock_event,
)
sleep = interval - (time.monotonic() - t0)
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict, elapsed: float,
lock_event: tuple | None) -> None:
if not self.is_mounted:
return
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
# Feed radar scope
radar = self.query_one("#track-radar", RadarScope)
radar.push(snr_db)
radar.set_locked(locked)
# Feed sparklines
self.query_one("#track-snr-spark", SparklineWidget).push(snr_db)
self.query_one("#track-power-spark", SparklineWidget).push(sig.get("power_db", -40))
# Update stats
self.query_one("#trk-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#trk-elapsed", Static).update(
f"[#506878]Elapsed:[/] [#00d4aa]{elapsed:.0f}s[/]"
)
self.query_one("#trk-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)
if lock_event:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log = self.query_one("#track-log", RichLog)
if lock_event[0] == "lock":
log.write(
f"[#506878]{ts}[/] [bold #00e060]LOCK ACQUIRED[/]"
f" SNR {lock_event[1]:.1f} dB"
)
else:
log.write(
f"[#506878]{ts}[/] [bold #e04040]LOCK LOST[/]"
)
def _log_event(self, markup: str) -> None:
"""Write a message to the event log (safe from any thread via call_from_thread)."""
if not self.is_mounted:
return
try:
self.query_one("#track-log", RichLog).write(markup)
except Exception:
pass
def _mark_stopped(self) -> None:
"""Update UI to stopped state (called from circuit breaker)."""
if not self.is_mounted:
return
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #e04040]Error[/]"
)
except Exception:
pass
def _export_csv(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.csv")
try:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(self._records[0].keys()))
w.writeheader()
w.writerows(self._records)
log.write(f"[#00d4aa]CSV exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
def _export_jsonl(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.jsonl")
try:
with open(path, "w") as f:
for rec in self._records:
f.write(json.dumps(rec) + "\n")
log.write(f"[#00d4aa]JSONL exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
"""Track screen — carrier/beacon tracker with radar scope, logging, and export.
Locks to a single frequency and records SNR, power, lock state over time.
Features a hero radar scope (P1 phosphor CRT aesthetic), dual sparklines,
event log for lock transitions, and stats. Supports CSV/JSONL export.
"""
import csv
import json
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Label, Input, Button, Static, RichLog
from textual import work
from textual.worker import Worker
from skywalker_tui.widgets.radar_scope import RadarScope
from skywalker_tui.widgets.sparkline_widget import SparklineWidget
class TrackScreen(Container):
"""Long-running carrier tracker with radar scope and event log."""
DEFAULT_CSS = """
TrackScreen {
layout: vertical;
}
TrackScreen #track-main {
height: 1fr;
layout: horizontal;
padding: 1 2;
}
TrackScreen #track-radar-col {
width: 1fr;
min-width: 30;
layout: vertical;
}
TrackScreen #track-radar-label {
height: 1;
color: #0a5a10;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-data-col {
width: 1fr;
layout: vertical;
padding: 0 0 0 1;
}
TrackScreen #track-sparklines {
height: auto;
layout: vertical;
}
TrackScreen #track-log-container {
height: 10;
background: #0e1018;
border: round #1a2a3a;
margin: 1 0;
}
TrackScreen #track-log-title {
height: 1;
color: #00d4aa;
text-style: bold;
padding: 0 1;
}
TrackScreen #track-log {
height: 1fr;
}
TrackScreen #track-stats {
height: 3;
layout: horizontal;
}
TrackScreen #track-stats Static {
width: 1fr;
height: 3;
content-align: center middle;
background: #121c2a;
border: round #1a3050;
margin: 0 1 0 0;
}
TrackScreen #track-controls {
height: auto;
padding: 1 2;
background: #0e1018;
border-top: solid #1a2a3a;
layout: horizontal;
}
TrackScreen #track-controls Label {
width: auto;
margin: 1 1 0 0;
color: #506878;
}
TrackScreen #track-controls Input {
width: 14;
margin: 0 1;
}
TrackScreen #track-controls Button {
margin: 0 1;
}
"""
def __init__(self, bridge, **kwargs):
super().__init__(**kwargs)
self._bridge = bridge
self._tracking = False
self._track_worker: Worker | None = None
self._sample_count = 0
self._peak_snr = 0.0
self._start_time = 0.0
self._was_locked: bool | None = None
self._records: deque[dict] = deque(maxlen=360_000) # ~10h at 10Hz
def compose(self) -> ComposeResult:
with Horizontal(id="track-main"):
with Vertical(id="track-radar-col"):
yield Static("[#0a5a10 bold]Radar Scope[/]", id="track-radar-label")
yield RadarScope(id="track-radar")
with Vertical(id="track-data-col"):
with Vertical(id="track-sparklines"):
yield SparklineWidget(title="SNR (dB)", color="#00d4aa",
id="track-snr-spark")
yield SparklineWidget(title="Power (dB)", color="#2196f3",
id="track-power-spark")
with Vertical(id="track-log-container"):
yield Static("[#00d4aa bold]Event Log[/]", id="track-log-title")
yield RichLog(id="track-log", wrap=True, markup=True)
with Horizontal(id="track-stats"):
yield Static("[#506878]Samples:[/] [#00d4aa]0[/]", id="trk-samples")
yield Static("[#506878]Elapsed:[/] [#00d4aa]0s[/]", id="trk-elapsed")
yield Static("[#506878]Peak SNR:[/] [#00d4aa]0.0 dB[/]", id="trk-peak")
yield Static("[#506878]Status:[/] [#e8a020]Stopped[/]", id="trk-status")
with Horizontal(id="track-controls"):
yield Label("Freq (MHz):")
yield Input("1200", id="trk-freq")
yield Label("SR (ksps):")
yield Input("20000", id="trk-sr")
yield Label("Rate (Hz):")
yield Input("1", id="trk-rate")
yield Button("Start", id="trk-start-btn", variant="success")
yield Button("Stop", id="trk-stop-btn", variant="error")
yield Button("Export CSV", id="trk-csv-btn")
yield Button("Export JSONL", id="trk-jsonl-btn")
def on_show(self) -> None:
if self._bridge.is_demo and not self._tracking:
self._start_tracking()
def on_hide(self) -> None:
self._stop_tracking()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "trk-start-btn":
self._start_tracking()
elif event.button.id == "trk-stop-btn":
self._stop_tracking()
elif event.button.id == "trk-csv-btn":
self._export_csv()
elif event.button.id == "trk-jsonl-btn":
self._export_jsonl()
def _start_tracking(self) -> None:
if self._tracking:
return
log = self.query_one("#track-log", RichLog)
# C3: Validate inputs before starting
try:
freq = float(self.query_one("#trk-freq", Input).value or "1200")
except ValueError:
log.write("[bold #e04040]Invalid frequency — must be a number[/]")
return
try:
sr = int(float(self.query_one("#trk-sr", Input).value or "20000"))
except ValueError:
log.write("[bold #e04040]Invalid symbol rate — must be a number[/]")
return
try:
rate = float(self.query_one("#trk-rate", Input).value or "1")
except ValueError:
log.write("[bold #e04040]Invalid rate — must be a number[/]")
return
if not (950 <= freq <= 2150):
log.write("[bold #e04040]Frequency out of range (9502150 MHz)[/]")
return
if not (256 <= sr <= 30000):
log.write("[bold #e04040]Symbol rate out of range (25630000 ksps)[/]")
return
if not (0.1 <= rate <= 100):
log.write("[bold #e04040]Rate out of range (0.1100 Hz)[/]")
return
self._tracking = True
self._sample_count = 0
self._peak_snr = 0.0
self._was_locked = None
self._records.clear()
self._start_time = time.monotonic()
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #00d4aa]Tracking[/]"
)
log.clear()
log.write("[#506878]Tracking started[/]")
self._track_worker = self._do_track(freq, sr, rate)
def _stop_tracking(self) -> None:
self._tracking = False
if self._track_worker:
self._track_worker.cancel()
self._track_worker = None
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [#e8a020]Stopped[/]"
)
log = self.query_one("#track-log", RichLog)
log.write(f"[#506878]Stopped. {self._sample_count} samples.[/]")
except Exception:
pass
@work(thread=True)
def _do_track(self, freq_mhz: float, sr_ksps: int, rate: float) -> None:
"""Background tracking loop with circuit breaker."""
max_consecutive_errors = 10
interval = 1.0 / max(0.1, rate)
freq_khz = int(freq_mhz * 1000)
sr_sps = sr_ksps * 1000
try:
self._bridge.ensure_booted()
self._bridge.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
except Exception as e:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Boot/tune failed:[/] [#e04040]{e}[/]",
)
consecutive_errors = 0
while self._tracking:
t0 = time.monotonic()
try:
sig = self._bridge.signal_monitor()
consecutive_errors = 0 # reset on success
except Exception:
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
self.app.call_from_thread(
self._log_event,
f"[bold #e04040]Circuit breaker: "
f"{max_consecutive_errors} consecutive errors, stopping[/]",
)
self._tracking = False
self.app.call_from_thread(self._mark_stopped)
return
time.sleep(interval)
continue
self._sample_count += 1
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
self._peak_snr = max(self._peak_snr, snr_db)
elapsed = time.monotonic() - self._start_time
record = {
"ts": datetime.now().isoformat(),
"elapsed": round(elapsed, 3),
"snr_db": round(snr_db, 2),
"agc1": sig.get("agc1", 0),
"agc2": sig.get("agc2", 0),
"power_db": round(sig.get("power_db", -40), 2),
"locked": locked,
}
self._records.append(record)
# Lock transition detection
lock_event = None
if self._was_locked is not None and locked != self._was_locked:
if locked:
lock_event = ("lock", snr_db)
else:
lock_event = ("unlock", snr_db)
self._was_locked = locked
self.app.call_from_thread(
self._update_ui, sig, elapsed, lock_event,
)
sleep = interval - (time.monotonic() - t0)
if sleep > 0:
time.sleep(sleep)
def _update_ui(self, sig: dict, elapsed: float,
lock_event: tuple | None) -> None:
if not self.is_mounted:
return
snr_db = sig.get("snr_db", 0.0)
locked = sig.get("locked", False)
# Feed radar scope
radar = self.query_one("#track-radar", RadarScope)
radar.push(snr_db)
radar.set_locked(locked)
# Feed sparklines
self.query_one("#track-snr-spark", SparklineWidget).push(snr_db)
self.query_one("#track-power-spark", SparklineWidget).push(sig.get("power_db", -40))
# Update stats
self.query_one("#trk-samples", Static).update(
f"[#506878]Samples:[/] [#00d4aa]{self._sample_count}[/]"
)
self.query_one("#trk-elapsed", Static).update(
f"[#506878]Elapsed:[/] [#00d4aa]{elapsed:.0f}s[/]"
)
self.query_one("#trk-peak", Static).update(
f"[#506878]Peak SNR:[/] [#00d4aa]{self._peak_snr:.1f} dB[/]"
)
if lock_event:
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log = self.query_one("#track-log", RichLog)
if lock_event[0] == "lock":
log.write(
f"[#506878]{ts}[/] [bold #00e060]LOCK ACQUIRED[/]"
f" SNR {lock_event[1]:.1f} dB"
)
else:
log.write(
f"[#506878]{ts}[/] [bold #e04040]LOCK LOST[/]"
)
def _log_event(self, markup: str) -> None:
"""Write a message to the event log (safe from any thread via call_from_thread)."""
if not self.is_mounted:
return
try:
self.query_one("#track-log", RichLog).write(markup)
except Exception:
pass
def _mark_stopped(self) -> None:
"""Update UI to stopped state (called from circuit breaker)."""
if not self.is_mounted:
return
try:
self.query_one("#trk-status", Static).update(
"[#506878]Status:[/] [bold #e04040]Error[/]"
)
except Exception:
pass
def _export_csv(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.csv")
try:
with open(path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(self._records[0].keys()))
w.writeheader()
w.writerows(self._records)
log.write(f"[#00d4aa]CSV exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")
def _export_jsonl(self) -> None:
if not self._records:
return
log = self.query_one("#track-log", RichLog)
path = Path(f"skywalker-track-{datetime.now().strftime('%Y%m%d-%H%M%S')}.jsonl")
try:
with open(path, "w") as f:
for rec in self._records:
f.write(json.dumps(rec) + "\n")
log.write(f"[#00d4aa]JSONL exported: {path}[/]")
except PermissionError:
log.write(f"[bold #e04040]Permission denied: {path}[/]")
except OSError as e:
log.write(f"[bold #e04040]Export failed: {e}[/]")

View file

@ -1,424 +1,424 @@
/* SkyWalker-1 TUI — dark RF theme
*
* Design: dark background with teal/cyan data accents.
* Signal gradient: blue → green → yellow → red (cold → hot).
* No purple per user preference.
*/
/* ─── Global ─── */
Screen {
background: #0a0a12;
color: #c8d0d8;
}
Header {
background: #0e1018;
color: #00d4aa;
dock: top;
}
Footer {
background: #0e1018;
dock: bottom;
}
/* ─── Sidebar ─── */
#sidebar {
width: 26;
background: #0e1420;
border-right: solid #1a2a3a;
padding: 1 1;
}
#sidebar .mode-button {
width: 100%;
margin: 0 0 1 0;
min-height: 3;
background: #121c2a;
color: #7090a8;
border: round #1a3050;
text-align: center;
}
#sidebar .mode-button:hover {
background: #1a2a40;
color: #00d4aa;
border: round #00d4aa;
}
#sidebar .mode-button.-active {
background: #0a2a3a;
color: #00d4aa;
border: round #00d4aa;
text-style: bold;
}
#sidebar Label.sidebar-heading {
color: #506878;
text-style: bold;
margin: 1 0 0 0;
text-align: center;
}
/* ─── Content area ─── */
#content-area {
background: #0a0a12;
}
/* ─── Status bar widget ─── */
#device-status {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
}
#device-status .status-label {
color: #506878;
}
#device-status .status-value {
color: #00d4aa;
}
#device-status .status-connected {
color: #00d4aa;
text-style: bold;
}
#device-status .status-demo {
color: #e8a020;
text-style: bold;
}
#device-status .status-disconnected {
color: #e04040;
text-style: bold;
}
/* ─── Signal gauge ─── */
.signal-gauge {
height: auto;
padding: 1;
}
.signal-gauge .snr-value {
color: #00d4aa;
text-style: bold;
}
.signal-gauge .lock-yes {
color: #00e060;
text-style: bold;
}
.signal-gauge .lock-no {
color: #e04040;
}
/* ─── Spectrum plot ─── */
.spectrum-plot {
min-height: 12;
}
/* ─── Panels and containers ─── */
.panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
}
.panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
/* ─── Controls / Input areas ─── */
.controls {
height: auto;
padding: 1;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
.controls Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
.controls Input {
width: 14;
background: #121c2a;
border: round #1a3050;
color: #c8d0d8;
}
.controls Input:focus {
border: round #00d4aa;
}
.controls Button {
margin: 0 1;
background: #1a2a40;
color: #00d4aa;
border: round #1a3050;
}
.controls Button:hover {
background: #00d4aa;
color: #0a0a12;
}
/* ─── Data table ─── */
DataTable {
background: #0a0a12;
}
DataTable > .datatable--header {
background: #0e1420;
color: #00d4aa;
text-style: bold;
}
DataTable > .datatable--cursor {
background: #1a2a40;
color: #ffffff;
}
/* ─── Progress bar ─── */
ProgressBar Bar {
color: #00d4aa;
background: #121c2a;
}
/* ─── Sparkline ─── */
.sparkline-widget {
height: 3;
padding: 0 1;
}
/* ─── Waterfall ─── */
.waterfall {
min-height: 10;
}
/* ─── Log / event list ─── */
#event-log {
height: 8;
background: #0e1018;
border: round #1a2a3a;
padding: 0 1;
overflow-y: auto;
}
#event-log .log-lock {
color: #00e060;
}
#event-log .log-unlock {
color: #e04040;
}
#event-log .log-time {
color: #506878;
}
/* ─── Stats panel ─── */
.stats-grid {
layout: grid;
grid-size: 4;
grid-gutter: 1;
height: auto;
padding: 1;
}
.stat-box {
height: 3;
background: #121c2a;
border: round #1a3050;
padding: 0 1;
content-align: center middle;
}
.stat-box .stat-value {
color: #00d4aa;
text-style: bold;
}
.stat-box .stat-label {
color: #506878;
}
/* ─── L-band allocation ─── */
.alloc-tag {
background: #1a2a40;
color: #60a0c0;
padding: 0 1;
margin: 0 1 0 0;
}
/* ─── Mode-specific screen layouts ─── */
.mode-screen {
layout: vertical;
}
.top-panel {
height: 1fr;
min-height: 10;
}
.bottom-panel {
height: auto;
}
.split-horizontal {
layout: horizontal;
}
.left-panel {
width: 1fr;
}
.right-panel {
width: 1fr;
}
/* ─── Radar scope ─── */
RadarScope {
min-height: 12;
min-width: 24;
height: 1fr;
background: #0a0a0a;
border: round #0a2a0a;
}
#track-radar-col {
width: 1fr;
min-width: 30;
}
/* ─── Splash screen overlay ─── */
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-image {
width: 100%;
height: 1fr;
content-align: center middle;
}
/* ─── Hex view ─── */
HexView {
min-height: 6;
}
/* ─── PID table ─── */
PidTable {
min-height: 8;
}
/* ─── PSI tree ─── */
PsiTree {
min-height: 8;
}
/* ─── Countdown timer ─── */
CountdownTimer {
margin: 1 0;
}
/* ─── Config bits display ─── */
ConfigBitsDisplay {
height: auto;
}
/* ─── Star Wars overlay ─── */
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
/* ─── Motor screen ─── */
MotorScreen .jog-row Button {
min-height: 3;
}
MotorScreen #jog-halt {
background: #3a1010;
color: #e04040;
border: round #e04040;
}
MotorScreen #jog-halt:hover {
background: #e04040;
color: #0a0a12;
}
MotorScreen #jog-east,
MotorScreen #jog-west {
background: #1a2a40;
color: #e8a020;
border: round #e8a020;
}
MotorScreen #jog-east:hover,
MotorScreen #jog-west:hover {
background: #e8a020;
color: #0a0a12;
}
/* ─── Survey / QO-100 screen ─── */
SurveyScreen TabbedContent ContentSwitcher {
height: 1fr;
}
SurveyScreen TabPane {
padding: 0;
}
/* SkyWalker-1 TUI — dark RF theme
*
* Design: dark background with teal/cyan data accents.
* Signal gradient: blue → green → yellow → red (cold → hot).
* No purple per user preference.
*/
/* ─── Global ─── */
Screen {
background: #0a0a12;
color: #c8d0d8;
}
Header {
background: #0e1018;
color: #00d4aa;
dock: top;
}
Footer {
background: #0e1018;
dock: bottom;
}
/* ─── Sidebar ─── */
#sidebar {
width: 26;
background: #0e1420;
border-right: solid #1a2a3a;
padding: 1 1;
}
#sidebar .mode-button {
width: 100%;
margin: 0 0 1 0;
min-height: 3;
background: #121c2a;
color: #7090a8;
border: round #1a3050;
text-align: center;
}
#sidebar .mode-button:hover {
background: #1a2a40;
color: #00d4aa;
border: round #00d4aa;
}
#sidebar .mode-button.-active {
background: #0a2a3a;
color: #00d4aa;
border: round #00d4aa;
text-style: bold;
}
#sidebar Label.sidebar-heading {
color: #506878;
text-style: bold;
margin: 1 0 0 0;
text-align: center;
}
/* ─── Content area ─── */
#content-area {
background: #0a0a12;
}
/* ─── Status bar widget ─── */
#device-status {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
}
#device-status .status-label {
color: #506878;
}
#device-status .status-value {
color: #00d4aa;
}
#device-status .status-connected {
color: #00d4aa;
text-style: bold;
}
#device-status .status-demo {
color: #e8a020;
text-style: bold;
}
#device-status .status-disconnected {
color: #e04040;
text-style: bold;
}
/* ─── Signal gauge ─── */
.signal-gauge {
height: auto;
padding: 1;
}
.signal-gauge .snr-value {
color: #00d4aa;
text-style: bold;
}
.signal-gauge .lock-yes {
color: #00e060;
text-style: bold;
}
.signal-gauge .lock-no {
color: #e04040;
}
/* ─── Spectrum plot ─── */
.spectrum-plot {
min-height: 12;
}
/* ─── Panels and containers ─── */
.panel {
background: #0e1420;
border: round #1a2a3a;
padding: 1;
margin: 0 0 1 0;
}
.panel-title {
color: #00d4aa;
text-style: bold;
margin: 0 0 1 0;
}
/* ─── Controls / Input areas ─── */
.controls {
height: auto;
padding: 1;
background: #0e1018;
border-top: solid #1a2a3a;
dock: bottom;
}
.controls Label {
color: #506878;
width: auto;
margin: 0 1 0 0;
}
.controls Input {
width: 14;
background: #121c2a;
border: round #1a3050;
color: #c8d0d8;
}
.controls Input:focus {
border: round #00d4aa;
}
.controls Button {
margin: 0 1;
background: #1a2a40;
color: #00d4aa;
border: round #1a3050;
}
.controls Button:hover {
background: #00d4aa;
color: #0a0a12;
}
/* ─── Data table ─── */
DataTable {
background: #0a0a12;
}
DataTable > .datatable--header {
background: #0e1420;
color: #00d4aa;
text-style: bold;
}
DataTable > .datatable--cursor {
background: #1a2a40;
color: #ffffff;
}
/* ─── Progress bar ─── */
ProgressBar Bar {
color: #00d4aa;
background: #121c2a;
}
/* ─── Sparkline ─── */
.sparkline-widget {
height: 3;
padding: 0 1;
}
/* ─── Waterfall ─── */
.waterfall {
min-height: 10;
}
/* ─── Log / event list ─── */
#event-log {
height: 8;
background: #0e1018;
border: round #1a2a3a;
padding: 0 1;
overflow-y: auto;
}
#event-log .log-lock {
color: #00e060;
}
#event-log .log-unlock {
color: #e04040;
}
#event-log .log-time {
color: #506878;
}
/* ─── Stats panel ─── */
.stats-grid {
layout: grid;
grid-size: 4;
grid-gutter: 1;
height: auto;
padding: 1;
}
.stat-box {
height: 3;
background: #121c2a;
border: round #1a3050;
padding: 0 1;
content-align: center middle;
}
.stat-box .stat-value {
color: #00d4aa;
text-style: bold;
}
.stat-box .stat-label {
color: #506878;
}
/* ─── L-band allocation ─── */
.alloc-tag {
background: #1a2a40;
color: #60a0c0;
padding: 0 1;
margin: 0 1 0 0;
}
/* ─── Mode-specific screen layouts ─── */
.mode-screen {
layout: vertical;
}
.top-panel {
height: 1fr;
min-height: 10;
}
.bottom-panel {
height: auto;
}
.split-horizontal {
layout: horizontal;
}
.left-panel {
width: 1fr;
}
.right-panel {
width: 1fr;
}
/* ─── Radar scope ─── */
RadarScope {
min-height: 12;
min-width: 24;
height: 1fr;
background: #0a0a0a;
border: round #0a2a0a;
}
#track-radar-col {
width: 1fr;
min-width: 30;
}
/* ─── Splash screen overlay ─── */
SplashScreen {
align: center middle;
background: #000000;
}
SplashScreen #splash-container {
width: 100%;
height: 100%;
align: center middle;
background: #000000;
}
SplashScreen #splash-image {
width: 100%;
height: 1fr;
content-align: center middle;
}
/* ─── Hex view ─── */
HexView {
min-height: 6;
}
/* ─── PID table ─── */
PidTable {
min-height: 8;
}
/* ─── PSI tree ─── */
PsiTree {
min-height: 8;
}
/* ─── Countdown timer ─── */
CountdownTimer {
margin: 1 0;
}
/* ─── Config bits display ─── */
ConfigBitsDisplay {
height: auto;
}
/* ─── Star Wars overlay ─── */
StarWarsScreen {
align: center middle;
background: #000000 90%;
}
StarWarsScreen #sw-container {
width: 90%;
height: 90%;
background: #000000;
border: round #1a3050;
}
/* ─── Motor screen ─── */
MotorScreen .jog-row Button {
min-height: 3;
}
MotorScreen #jog-halt {
background: #3a1010;
color: #e04040;
border: round #e04040;
}
MotorScreen #jog-halt:hover {
background: #e04040;
color: #0a0a12;
}
MotorScreen #jog-east,
MotorScreen #jog-west {
background: #1a2a40;
color: #e8a020;
border: round #e8a020;
}
MotorScreen #jog-east:hover,
MotorScreen #jog-west:hover {
background: #e8a020;
color: #0a0a12;
}
/* ─── Survey / QO-100 screen ─── */
SurveyScreen TabbedContent ContentSwitcher {
height: 1fr;
}
SurveyScreen TabPane {
padding: 0;
}

View file

@ -1 +1 @@
"""Custom widgets for SkyWalker-1 TUI."""
"""Custom widgets for SkyWalker-1 TUI."""

View file

@ -1,46 +1,46 @@
"""Config byte flag display with colored indicators.
Renders the 8PSK config byte as a horizontal row of labeled flags,
each shown as a filled (set) or hollow (clear) circle with color coding.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from skywalker_lib import CONFIG_BITS
class ConfigBitsDisplay(Widget):
"""Renders the 8PSK config byte as labeled flags with colored indicators."""
DEFAULT_CSS = """
ConfigBitsDisplay {
height: auto;
padding: 0 1;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._config = 0
def compose(self) -> ComposeResult:
yield Static("", id="config-bits-content")
def update_config(self, config: int) -> None:
"""Update the displayed config byte value."""
self._config = config
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted:
return
parts = []
for bit_mask, (name, _field) in CONFIG_BITS.items():
is_set = bool(self._config & bit_mask)
if is_set:
parts.append(f"[bold #00e060]\u25cf[/] [#c8d0d8]{name}[/]")
else:
parts.append(f"[#3a3a3a]\u25cb[/] [#506878]{name}[/]")
self.query_one("#config-bits-content", Static).update(" ".join(parts))
"""Config byte flag display with colored indicators.
Renders the 8PSK config byte as a horizontal row of labeled flags,
each shown as a filled (set) or hollow (clear) circle with color coding.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from skywalker_lib import CONFIG_BITS
class ConfigBitsDisplay(Widget):
"""Renders the 8PSK config byte as labeled flags with colored indicators."""
DEFAULT_CSS = """
ConfigBitsDisplay {
height: auto;
padding: 0 1;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._config = 0
def compose(self) -> ComposeResult:
yield Static("", id="config-bits-content")
def update_config(self, config: int) -> None:
"""Update the displayed config byte value."""
self._config = config
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted:
return
parts = []
for bit_mask, (name, _field) in CONFIG_BITS.items():
is_set = bool(self._config & bit_mask)
if is_set:
parts.append(f"[bold #00e060]\u25cf[/] [#c8d0d8]{name}[/]")
else:
parts.append(f"[#3a3a3a]\u25cb[/] [#506878]{name}[/]")
self.query_one("#config-bits-content", Static).update(" ".join(parts))

View file

@ -1,112 +1,112 @@
"""Countdown timer widget with ABORT button for safety-critical operations.
Used before EEPROM write to give the operator 3 seconds to abort.
The ABORT button is auto-focused on mount so a single Enter/Space press cancels.
"""
import time
from textual.widget import Widget
from textual.widgets import Button, Static, ProgressBar
from textual.app import ComposeResult
from textual.message import Message
from textual import work
class CountdownTimer(Widget):
"""3-second countdown with prominent ABORT button.
Posts CountdownTimer.Completed when the countdown finishes, or
CountdownTimer.Aborted if the operator presses ABORT.
"""
class Completed(Message):
"""Fired when countdown finishes without abort."""
pass
class Aborted(Message):
"""Fired when user presses ABORT."""
pass
DEFAULT_CSS = """
CountdownTimer {
height: auto;
background: #1a0a0a;
border: round #e04040;
padding: 1 2;
}
CountdownTimer #countdown-label {
text-align: center;
color: #e8a020;
text-style: bold;
margin: 0 0 1 0;
}
CountdownTimer #countdown-bar {
margin: 0 0 1 0;
}
CountdownTimer #countdown-abort {
width: 100%;
min-height: 3;
background: #e04040;
color: #ffffff;
text-style: bold;
border: round #ff6060;
}
CountdownTimer #countdown-abort:hover {
background: #ff4040;
}
CountdownTimer #countdown-abort:focus {
background: #ff2020;
border: round #ffffff;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._running = False
self._aborted = False
def compose(self) -> ComposeResult:
yield Static("EEPROM WRITE in 3...", id="countdown-label")
yield ProgressBar(
total=30, show_eta=False, show_percentage=False, id="countdown-bar"
)
yield Button("ABORT", id="countdown-abort", variant="error")
def on_mount(self) -> None:
self.query_one("#countdown-abort", Button).focus()
def start(self) -> None:
"""Begin the 3-second countdown. Call after mounting."""
self._running = True
self._aborted = False
self._do_countdown()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "countdown-abort":
self._aborted = True
self._running = False
self.post_message(self.Aborted())
@work(thread=True)
def _do_countdown(self) -> None:
"""Tick at 100ms intervals for smooth progress bar animation."""
for tick in range(30, -1, -1):
if not self._running or self._aborted:
return
secs = tick / 10
self.app.call_from_thread(self._update_display, secs, 30 - tick)
time.sleep(0.1)
if self._running and not self._aborted:
self.app.call_from_thread(self._fire_completed)
def _update_display(self, secs: float, progress: int) -> None:
if not self.is_mounted:
return
label = self.query_one("#countdown-label", Static)
label.update(f"EEPROM WRITE in {secs:.1f}s \u2014 press ABORT to cancel")
self.query_one("#countdown-bar", ProgressBar).update(progress=progress)
def _fire_completed(self) -> None:
if self.is_mounted and not self._aborted:
self.post_message(self.Completed())
"""Countdown timer widget with ABORT button for safety-critical operations.
Used before EEPROM write to give the operator 3 seconds to abort.
The ABORT button is auto-focused on mount so a single Enter/Space press cancels.
"""
import time
from textual.widget import Widget
from textual.widgets import Button, Static, ProgressBar
from textual.app import ComposeResult
from textual.message import Message
from textual import work
class CountdownTimer(Widget):
"""3-second countdown with prominent ABORT button.
Posts CountdownTimer.Completed when the countdown finishes, or
CountdownTimer.Aborted if the operator presses ABORT.
"""
class Completed(Message):
"""Fired when countdown finishes without abort."""
pass
class Aborted(Message):
"""Fired when user presses ABORT."""
pass
DEFAULT_CSS = """
CountdownTimer {
height: auto;
background: #1a0a0a;
border: round #e04040;
padding: 1 2;
}
CountdownTimer #countdown-label {
text-align: center;
color: #e8a020;
text-style: bold;
margin: 0 0 1 0;
}
CountdownTimer #countdown-bar {
margin: 0 0 1 0;
}
CountdownTimer #countdown-abort {
width: 100%;
min-height: 3;
background: #e04040;
color: #ffffff;
text-style: bold;
border: round #ff6060;
}
CountdownTimer #countdown-abort:hover {
background: #ff4040;
}
CountdownTimer #countdown-abort:focus {
background: #ff2020;
border: round #ffffff;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._running = False
self._aborted = False
def compose(self) -> ComposeResult:
yield Static("EEPROM WRITE in 3...", id="countdown-label")
yield ProgressBar(
total=30, show_eta=False, show_percentage=False, id="countdown-bar"
)
yield Button("ABORT", id="countdown-abort", variant="error")
def on_mount(self) -> None:
self.query_one("#countdown-abort", Button).focus()
def start(self) -> None:
"""Begin the 3-second countdown. Call after mounting."""
self._running = True
self._aborted = False
self._do_countdown()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "countdown-abort":
self._aborted = True
self._running = False
self.post_message(self.Aborted())
@work(thread=True)
def _do_countdown(self) -> None:
"""Tick at 100ms intervals for smooth progress bar animation."""
for tick in range(30, -1, -1):
if not self._running or self._aborted:
return
secs = tick / 10
self.app.call_from_thread(self._update_display, secs, 30 - tick)
time.sleep(0.1)
if self._running and not self._aborted:
self.app.call_from_thread(self._fire_completed)
def _update_display(self, secs: float, progress: int) -> None:
if not self.is_mounted:
return
label = self.query_one("#countdown-label", Static)
label.update(f"EEPROM WRITE in {secs:.1f}s \u2014 press ABORT to cancel")
self.query_one("#countdown-bar", ProgressBar).update(progress=progress)
def _fire_completed(self) -> None:
if self.is_mounted and not self._aborted:
self.post_message(self.Completed())

View file

@ -1,81 +1,81 @@
"""DataTable wrapper for transponder scan results."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
from skywalker_lib import LBAND_ALLOCATIONS
def _freq_allocation(freq_mhz: float) -> str:
"""Return allocation name for a given frequency."""
for lo, hi, name in LBAND_ALLOCATIONS:
if lo <= freq_mhz <= hi:
return name
return ""
class FrequencyTable(Widget):
"""Sortable table of discovered transponders or sweep results."""
DEFAULT_CSS = """
FrequencyTable {
height: 1fr;
min-height: 6;
}
"""
def __init__(self, show_allocation: bool = False, **kwargs):
super().__init__(**kwargs)
self._show_allocation = show_allocation
self._rows: list[dict] = []
def compose(self) -> ComposeResult:
table = DataTable(id="freq-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#freq-table", DataTable)
cols = ["IF MHz", "RF MHz", "SR ksps", "Power dB", "Locked"]
if self._show_allocation:
cols.append("Allocation")
for col in cols:
table.add_column(col, key=col)
def add_transponder(self, tp: dict) -> None:
"""Add a single transponder result."""
self._rows.append(tp)
table = self.query_one("#freq-table", DataTable)
if_mhz = tp.get("if_mhz", 0)
rf_mhz = tp.get("rf_mhz", 0)
sr_ksps = tp.get("sr_ksps", 0)
power_db = tp.get("power_db", 0)
locked = "Yes" if tp.get("locked", False) else "No"
row = [f"{if_mhz:.1f}", f"{rf_mhz:.0f}", str(sr_ksps),
f"{power_db:.1f}", locked]
if self._show_allocation:
row.append(_freq_allocation(if_mhz))
table.add_row(*row)
def clear_table(self) -> None:
"""Remove all rows."""
self._rows.clear()
table = self.query_one("#freq-table", DataTable)
table.clear()
def get_selected_transponder(self) -> dict | None:
"""Return the currently selected transponder dict."""
table = self.query_one("#freq-table", DataTable)
cursor_row = table.cursor_row
if cursor_row is not None and 0 <= cursor_row < len(self._rows):
return self._rows[cursor_row]
return None
"""DataTable wrapper for transponder scan results."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
from skywalker_lib import LBAND_ALLOCATIONS
def _freq_allocation(freq_mhz: float) -> str:
"""Return allocation name for a given frequency."""
for lo, hi, name in LBAND_ALLOCATIONS:
if lo <= freq_mhz <= hi:
return name
return ""
class FrequencyTable(Widget):
"""Sortable table of discovered transponders or sweep results."""
DEFAULT_CSS = """
FrequencyTable {
height: 1fr;
min-height: 6;
}
"""
def __init__(self, show_allocation: bool = False, **kwargs):
super().__init__(**kwargs)
self._show_allocation = show_allocation
self._rows: list[dict] = []
def compose(self) -> ComposeResult:
table = DataTable(id="freq-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#freq-table", DataTable)
cols = ["IF MHz", "RF MHz", "SR ksps", "Power dB", "Locked"]
if self._show_allocation:
cols.append("Allocation")
for col in cols:
table.add_column(col, key=col)
def add_transponder(self, tp: dict) -> None:
"""Add a single transponder result."""
self._rows.append(tp)
table = self.query_one("#freq-table", DataTable)
if_mhz = tp.get("if_mhz", 0)
rf_mhz = tp.get("rf_mhz", 0)
sr_ksps = tp.get("sr_ksps", 0)
power_db = tp.get("power_db", 0)
locked = "Yes" if tp.get("locked", False) else "No"
row = [f"{if_mhz:.1f}", f"{rf_mhz:.0f}", str(sr_ksps),
f"{power_db:.1f}", locked]
if self._show_allocation:
row.append(_freq_allocation(if_mhz))
table.add_row(*row)
def clear_table(self) -> None:
"""Remove all rows."""
self._rows.clear()
table = self.query_one("#freq-table", DataTable)
table.clear()
def get_selected_transponder(self) -> dict | None:
"""Return the currently selected transponder dict."""
table = self.query_one("#freq-table", DataTable)
cursor_row = table.cursor_row
if cursor_row is not None and 0 <= cursor_row < len(self._rows):
return self._rows[cursor_row]
return None

View file

@ -1,90 +1,90 @@
"""Scrollable hex dump widget with diff byte highlighting."""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
class HexView(Widget):
"""Displays a hex dump of binary data with optional diff highlighting.
Set data via set_data(), optionally passing a set of byte offsets to
highlight in red (for verify mismatches). Each row shows 16 bytes in
traditional offset : hex : ASCII layout.
"""
DEFAULT_CSS = """
HexView {
height: 1fr;
min-height: 6;
background: #0e1420;
border: round #1a2a3a;
}
HexView #hex-scroll {
height: 1fr;
padding: 0 1;
}
HexView #hex-content {
width: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._data = b''
self._diff_offsets: set[int] = set()
def compose(self) -> ComposeResult:
with VerticalScroll(id="hex-scroll"):
yield Static("", id="hex-content")
def set_data(self, data: bytes, diff_offsets: set[int] | None = None) -> None:
"""Set data to display. diff_offsets highlights those bytes in red."""
self._data = data
self._diff_offsets = diff_offsets or set()
self._refresh_display()
def clear(self) -> None:
"""Clear the hex display."""
self._data = b''
self._diff_offsets.clear()
if self.is_mounted:
self.query_one("#hex-content", Static).update("")
def _refresh_display(self) -> None:
if not self.is_mounted:
return
lines = []
for row_off in range(0, len(self._data), 16):
row = self._data[row_off:row_off + 16]
# Offset column
line = f"[#506878]{row_off:04X}:[/] "
# Hex bytes
hex_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
if abs_off in self._diff_offsets:
hex_parts.append(f"[bold #e04040]{b:02X}[/]")
else:
hex_parts.append(f"[#7090a8]{b:02X}[/]")
line += " ".join(hex_parts)
# Pad if short row
if len(row) < 16:
line += " " * (16 - len(row))
# ASCII column
line += " "
ascii_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
ch = chr(b) if 0x20 <= b < 0x7F else "."
if abs_off in self._diff_offsets:
ascii_parts.append(f"[bold #e04040]{ch}[/]")
else:
ascii_parts.append(f"[#506878]{ch}[/]")
line += "".join(ascii_parts)
lines.append(line)
self.query_one("#hex-content", Static).update(
"\n".join(lines) if lines else "[#506878]No data[/]"
)
"""Scrollable hex dump widget with diff byte highlighting."""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
class HexView(Widget):
"""Displays a hex dump of binary data with optional diff highlighting.
Set data via set_data(), optionally passing a set of byte offsets to
highlight in red (for verify mismatches). Each row shows 16 bytes in
traditional offset : hex : ASCII layout.
"""
DEFAULT_CSS = """
HexView {
height: 1fr;
min-height: 6;
background: #0e1420;
border: round #1a2a3a;
}
HexView #hex-scroll {
height: 1fr;
padding: 0 1;
}
HexView #hex-content {
width: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._data = b''
self._diff_offsets: set[int] = set()
def compose(self) -> ComposeResult:
with VerticalScroll(id="hex-scroll"):
yield Static("", id="hex-content")
def set_data(self, data: bytes, diff_offsets: set[int] | None = None) -> None:
"""Set data to display. diff_offsets highlights those bytes in red."""
self._data = data
self._diff_offsets = diff_offsets or set()
self._refresh_display()
def clear(self) -> None:
"""Clear the hex display."""
self._data = b''
self._diff_offsets.clear()
if self.is_mounted:
self.query_one("#hex-content", Static).update("")
def _refresh_display(self) -> None:
if not self.is_mounted:
return
lines = []
for row_off in range(0, len(self._data), 16):
row = self._data[row_off:row_off + 16]
# Offset column
line = f"[#506878]{row_off:04X}:[/] "
# Hex bytes
hex_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
if abs_off in self._diff_offsets:
hex_parts.append(f"[bold #e04040]{b:02X}[/]")
else:
hex_parts.append(f"[#7090a8]{b:02X}[/]")
line += " ".join(hex_parts)
# Pad if short row
if len(row) < 16:
line += " " * (16 - len(row))
# ASCII column
line += " "
ascii_parts = []
for i, b in enumerate(row):
abs_off = row_off + i
ch = chr(b) if 0x20 <= b < 0x7F else "."
if abs_off in self._diff_offsets:
ascii_parts.append(f"[bold #e04040]{ch}[/]")
else:
ascii_parts.append(f"[#506878]{ch}[/]")
line += "".join(ascii_parts)
lines.append(line)
self.query_one("#hex-content", Static).update(
"\n".join(lines) if lines else "[#506878]No data[/]"
)

View file

@ -1,74 +1,74 @@
"""DataTable wrapper for MPEG-2 TS PID distribution statistics.
Displays per-PID packet counts, percentage share, continuity counter errors,
and well-known PID names. Table is rebuilt on each update to keep the sort
order stable (by PID number ascending).
"""
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
class PidTable(Widget):
"""Sortable PID statistics table for transport stream analysis."""
DEFAULT_CSS = """
PidTable {
height: 1fr;
min-height: 8;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pid_data: dict[int, dict] = {}
self._total_packets = 0
def compose(self) -> ComposeResult:
table = DataTable(id="pid-stats-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#pid-stats-table", DataTable)
for col in ["PID", "Count", "%", "CC Errors", "Name"]:
table.add_column(col, key=col)
def update_pids(
self,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total: int,
known_pids: dict[int, str],
) -> None:
"""Rebuild the table from accumulated stats.
Args:
pid_counts: Mapping of PID number to total packet count.
cc_errors: Mapping of PID number to continuity counter error count.
total: Total packet count across all PIDs.
known_pids: Mapping of PID number to human-readable name.
"""
self._total_packets = total
table = self.query_one("#pid-stats-table", DataTable)
table.clear()
for pid in sorted(pid_counts.keys()):
count = pid_counts[pid]
pct = (count / total * 100) if total > 0 else 0.0
cc_err = cc_errors.get(pid, 0)
name = known_pids.get(pid, "")
table.add_row(
f"0x{pid:04X}",
f"{count:,}",
f"{pct:.1f}%",
str(cc_err) if cc_err > 0 else "-",
name,
)
def clear_table(self) -> None:
"""Remove all rows and reset internal state."""
self._pid_data.clear()
self._total_packets = 0
self.query_one("#pid-stats-table", DataTable).clear()
"""DataTable wrapper for MPEG-2 TS PID distribution statistics.
Displays per-PID packet counts, percentage share, continuity counter errors,
and well-known PID names. Table is rebuilt on each update to keep the sort
order stable (by PID number ascending).
"""
from textual.widget import Widget
from textual.widgets import DataTable
from textual.app import ComposeResult
class PidTable(Widget):
"""Sortable PID statistics table for transport stream analysis."""
DEFAULT_CSS = """
PidTable {
height: 1fr;
min-height: 8;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pid_data: dict[int, dict] = {}
self._total_packets = 0
def compose(self) -> ComposeResult:
table = DataTable(id="pid-stats-table")
table.cursor_type = "row"
yield table
def on_mount(self) -> None:
table = self.query_one("#pid-stats-table", DataTable)
for col in ["PID", "Count", "%", "CC Errors", "Name"]:
table.add_column(col, key=col)
def update_pids(
self,
pid_counts: dict[int, int],
cc_errors: dict[int, int],
total: int,
known_pids: dict[int, str],
) -> None:
"""Rebuild the table from accumulated stats.
Args:
pid_counts: Mapping of PID number to total packet count.
cc_errors: Mapping of PID number to continuity counter error count.
total: Total packet count across all PIDs.
known_pids: Mapping of PID number to human-readable name.
"""
self._total_packets = total
table = self.query_one("#pid-stats-table", DataTable)
table.clear()
for pid in sorted(pid_counts.keys()):
count = pid_counts[pid]
pct = (count / total * 100) if total > 0 else 0.0
cc_err = cc_errors.get(pid, 0)
name = known_pids.get(pid, "")
table.add_row(
f"0x{pid:04X}",
f"{count:,}",
f"{pct:.1f}%",
str(cc_err) if cc_err > 0 else "-",
name,
)
def clear_table(self) -> None:
"""Remove all rows and reset internal state."""
self._pid_data.clear()
self._total_packets = 0
self.query_one("#pid-stats-table", DataTable).clear()

View file

@ -1,111 +1,111 @@
"""Tree-style display of MPEG-2 PSI structure (PAT/PMT).
Renders a hierarchical view of the Program Association Table and its
child Program Map Tables using Rich markup inside a Static widget.
Shows transport stream ID, program numbers, PMT PIDs, PCR PIDs,
and elementary stream types with their PIDs.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
class PsiTree(Widget):
"""Hierarchical PAT/PMT display for transport stream program structure."""
DEFAULT_CSS = """
PsiTree {
height: 1fr;
min-height: 8;
background: #0e1420;
border: round #1a2a3a;
padding: 1;
overflow-y: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pat: dict | None = None
self._pmts: dict[int, dict] = {}
def compose(self) -> ComposeResult:
yield Static(
"[#506878]Waiting for PSI data...[/]", id="psi-content"
)
def update_pat(self, pat: dict) -> None:
"""Update the Program Association Table and redraw."""
self._pat = pat
self._refresh()
def update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Update a Program Map Table and redraw."""
self._pmts[pmt_pid] = pmt
self._refresh()
def clear_tree(self) -> None:
"""Reset all PSI state and show placeholder."""
self._pat = None
self._pmts.clear()
if self.is_mounted:
self.query_one("#psi-content", Static).update(
"[#506878]Waiting for PSI data...[/]"
)
def _refresh(self) -> None:
"""Rebuild the tree markup from current PAT/PMT data."""
if not self.is_mounted:
return
lines: list[str] = []
if self._pat:
tsid = self._pat.get("transport_stream_id", 0)
ver = self._pat.get("version", 0)
lines.append(
f"[bold #00d4aa]PAT[/] [#506878]TSID=0x{tsid:04X} v{ver}[/]"
)
programs = self._pat.get("programs", {})
# Separate NIT (program 0) from real programs
real_progs = {k: v for k, v in programs.items() if k != 0}
for prog_num, pmt_pid in sorted(programs.items()):
if prog_num == 0:
lines.append(
f" [#7090a8]\u251c\u2500[/] [#506878]NIT[/] "
f"PID=0x{pmt_pid:04X}"
)
else:
is_last = prog_num == max(real_progs.keys())
prefix = "\u2514\u2500" if is_last else "\u251c\u2500"
lines.append(
f" [#7090a8]{prefix}[/] "
f"[bold #c8d0d8]Program {prog_num}[/] "
f"PMT=0x{pmt_pid:04X}"
)
# Expand PMT details if available
if pmt_pid in self._pmts:
pmt = self._pmts[pmt_pid]
pcr_pid = pmt.get("pcr_pid", 0)
indent = " " if is_last else "\u2502 "
lines.append(
f" {indent} [#506878]PCR PID=0x{pcr_pid:04X}[/]"
)
streams = pmt.get("streams", [])
for j, s in enumerate(streams):
s_last = j == len(streams) - 1
s_prefix = "\u2514\u2500" if s_last else "\u251c\u2500"
type_name = s.get("type_name", "Unknown")
epid = s.get("elementary_pid", 0)
lines.append(
f" {indent} [#7090a8]{s_prefix}[/] "
f"[#c8d0d8]{type_name}[/] "
f"PID=0x{epid:04X}"
)
else:
lines.append("[#506878]No PAT received yet[/]")
self.query_one("#psi-content", Static).update("\n".join(lines))
"""Tree-style display of MPEG-2 PSI structure (PAT/PMT).
Renders a hierarchical view of the Program Association Table and its
child Program Map Tables using Rich markup inside a Static widget.
Shows transport stream ID, program numbers, PMT PIDs, PCR PIDs,
and elementary stream types with their PIDs.
"""
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
class PsiTree(Widget):
"""Hierarchical PAT/PMT display for transport stream program structure."""
DEFAULT_CSS = """
PsiTree {
height: 1fr;
min-height: 8;
background: #0e1420;
border: round #1a2a3a;
padding: 1;
overflow-y: auto;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._pat: dict | None = None
self._pmts: dict[int, dict] = {}
def compose(self) -> ComposeResult:
yield Static(
"[#506878]Waiting for PSI data...[/]", id="psi-content"
)
def update_pat(self, pat: dict) -> None:
"""Update the Program Association Table and redraw."""
self._pat = pat
self._refresh()
def update_pmt(self, pmt_pid: int, pmt: dict) -> None:
"""Update a Program Map Table and redraw."""
self._pmts[pmt_pid] = pmt
self._refresh()
def clear_tree(self) -> None:
"""Reset all PSI state and show placeholder."""
self._pat = None
self._pmts.clear()
if self.is_mounted:
self.query_one("#psi-content", Static).update(
"[#506878]Waiting for PSI data...[/]"
)
def _refresh(self) -> None:
"""Rebuild the tree markup from current PAT/PMT data."""
if not self.is_mounted:
return
lines: list[str] = []
if self._pat:
tsid = self._pat.get("transport_stream_id", 0)
ver = self._pat.get("version", 0)
lines.append(
f"[bold #00d4aa]PAT[/] [#506878]TSID=0x{tsid:04X} v{ver}[/]"
)
programs = self._pat.get("programs", {})
# Separate NIT (program 0) from real programs
real_progs = {k: v for k, v in programs.items() if k != 0}
for prog_num, pmt_pid in sorted(programs.items()):
if prog_num == 0:
lines.append(
f" [#7090a8]\u251c\u2500[/] [#506878]NIT[/] "
f"PID=0x{pmt_pid:04X}"
)
else:
is_last = prog_num == max(real_progs.keys())
prefix = "\u2514\u2500" if is_last else "\u251c\u2500"
lines.append(
f" [#7090a8]{prefix}[/] "
f"[bold #c8d0d8]Program {prog_num}[/] "
f"PMT=0x{pmt_pid:04X}"
)
# Expand PMT details if available
if pmt_pid in self._pmts:
pmt = self._pmts[pmt_pid]
pcr_pid = pmt.get("pcr_pid", 0)
indent = " " if is_last else "\u2502 "
lines.append(
f" {indent} [#506878]PCR PID=0x{pcr_pid:04X}[/]"
)
streams = pmt.get("streams", [])
for j, s in enumerate(streams):
s_last = j == len(streams) - 1
s_prefix = "\u2514\u2500" if s_last else "\u251c\u2500"
type_name = s.get("type_name", "Unknown")
epid = s.get("elementary_pid", 0)
lines.append(
f" {indent} [#7090a8]{s_prefix}[/] "
f"[#c8d0d8]{type_name}[/] "
f"PID=0x{epid:04X}"
)
else:
lines.append("[#506878]No PAT received yet[/]")
self.query_one("#psi-content", Static).update("\n".join(lines))

View file

@ -1,276 +1,276 @@
"""Radar scope widget — P1 green phosphor CRT aesthetic.
Renders a circular radar display using half-block characters for 2x vertical
resolution. Each terminal cell becomes two pixel rows via the character
with independent fg (top) and bg (bottom) colors.
The sweep beam rotates through 360 positions. Signal strength determines
radial distance from center. Older samples decay in brightness (phosphor
persistence). Concentric range rings and a crosshair provide scale reference.
"""
import math
from collections import deque
from textual.widget import Widget
from textual.strip import Strip
from rich.segment import Segment
from rich.style import Style
# P1 green phosphor palette — 8 intensity levels from dead to peak burn
PHOSPHOR = [
"#0a0a0a", # background / dead pixel
"#0a1a0a", # very faint trace
"#0a2a0a", # dim afterglow
"#0a3a0a", # fading
"#0a4a0f", # moderate
"#0a5a10", # bright trace
"#0a6a15", # very bright
"#10ff30", # peak phosphor burn
]
PHOSPHOR_STYLES = [Style(color=c) for c in PHOSPHOR]
BG_COLOR = "#0a0a0a"
BG_STYLE = Style(color=BG_COLOR, bgcolor=BG_COLOR)
RING_COLOR = "#0a2a0a"
CROSSHAIR_COLOR = "#0a3a0a"
LOCK_RING_COLOR = "#10ff30"
class RadarScope(Widget):
"""Circular radar scope with phosphor decay and sweep beam."""
DEFAULT_CSS = """
RadarScope {
min-height: 12;
min-width: 24;
background: #0a0a0a;
}
"""
def __init__(self, max_samples: int = 360, **kwargs):
super().__init__(**kwargs)
self._samples: deque[float] = deque([0.0] * max_samples, maxlen=max_samples)
self._max_samples = max_samples
self._angle_idx = 0 # current sweep position (0..max_samples-1)
self._locked = False
# Pre-computed geometry + LUTs (rebuilt on resize)
self._cx = 0.0
self._cy = 0.0
self._radius = 0.0
self._cols = 0
self._rows = 0 # pixel rows (2x terminal rows)
# Per-pixel LUTs: distance from center, angle-to-sample index
self._dist_lut: list[list[float]] = []
self._angle_lut: list[list[int]] = []
# Per-pixel dx/dy for crosshair checks
self._dx_lut: list[list[float]] = []
self._dy_lut: list[list[float]] = []
def push(self, snr_db: float, max_snr: float = 16.0) -> None:
"""Push a new signal sample. SNR is normalized to 0.0-1.0 range."""
normalized = max(0.0, min(1.0, snr_db / max(0.1, max_snr)))
self._samples.append(normalized)
self._angle_idx = (self._angle_idx + 1) % self._max_samples
self.refresh()
def set_locked(self, locked: bool) -> None:
if locked != self._locked:
self._locked = locked
self.refresh()
def _recompute_geometry(self, width: int, height: int) -> None:
"""Recompute center, radius, and per-pixel LUTs for current dimensions.
Pre-computes dist/angle for every pixel so render_line() avoids
math.sqrt/math.atan2 per pixel per frame ~O(1) lookup instead.
"""
self._cols = width
self._rows = height * 2 # 2x vertical resolution via half-blocks
# Radius fits within both dimensions, accounting for ~2:1 terminal char aspect
rx = (width - 2) / 2.0
ry = (height * 2 - 2) / 2.0
self._radius = min(rx, ry)
cx = width / 2.0
cy = height # center in pixel rows (height * 2 / 2)
self._cx = cx
self._cy = cy
# Build LUTs for all pixel rows (height * 2) × columns
pixel_rows = height * 2
dist_lut = []
angle_lut = []
dx_lut = []
dy_lut = []
sqrt = math.sqrt
atan2 = math.atan2
degrees = math.degrees
ms = self._max_samples
for py in range(pixel_rows):
d_row = []
a_row = []
dxr = []
dyr = []
dy = py - cy
for px in range(width):
dx = px - cx
dist = sqrt(dx * dx + dy * dy)
d_row.append(dist)
dxr.append(dx)
dyr.append(dy)
if dist >= 1.0:
angle = atan2(dy, dx)
angle_deg = (degrees(angle) + 360) % 360
a_row.append(int(angle_deg / 360 * ms) % ms)
else:
a_row.append(0) # center pixel — angle irrelevant
dist_lut.append(d_row)
angle_lut.append(a_row)
dx_lut.append(dxr)
dy_lut.append(dyr)
self._dist_lut = dist_lut
self._angle_lut = angle_lut
self._dx_lut = dx_lut
self._dy_lut = dy_lut
def render_line(self, y: int) -> Strip:
"""Render one terminal row of the radar scope."""
width = self.size.width
height = self.size.height
if width < 4 or height < 4:
return Strip([Segment(" " * width, BG_STYLE)])
if self._cols != width or self._rows != height * 2:
self._recompute_geometry(width, height)
radius = self._radius
if radius < 2:
return Strip([Segment(" " * width, BG_STYLE)])
# Snapshot mutable state for consistent rendering across the frame
samples = list(self._samples)
angle_idx = self._angle_idx
locked = self._locked
# Two pixel rows per terminal row
py_top = y * 2
py_bot = y * 2 + 1
# LUT rows for this terminal row
dist_top = self._dist_lut[py_top] if py_top < len(self._dist_lut) else None
dist_bot = self._dist_lut[py_bot] if py_bot < len(self._dist_lut) else None
angle_top = self._angle_lut[py_top] if py_top < len(self._angle_lut) else None
angle_bot = self._angle_lut[py_bot] if py_bot < len(self._angle_lut) else None
dx_top = self._dx_lut[py_top] if py_top < len(self._dx_lut) else None
dx_bot = self._dx_lut[py_bot] if py_bot < len(self._dx_lut) else None
dy_top = self._dy_lut[py_top] if py_top < len(self._dy_lut) else None
dy_bot = self._dy_lut[py_bot] if py_bot < len(self._dy_lut) else None
if not dist_top or not dist_bot:
return Strip([Segment(" " * width, BG_STYLE)])
_pi = self._pixel_intensity
segments = []
for x in range(width):
top_intensity = _pi(
dist_top[x], angle_top[x], dx_top[x], dy_top[x],
radius, samples, angle_idx, locked,
)
bot_intensity = _pi(
dist_bot[x], angle_bot[x], dx_bot[x], dy_bot[x],
radius, samples, angle_idx, locked,
)
top_color = PHOSPHOR[top_intensity]
bot_color = PHOSPHOR[bot_intensity]
# ▀ char: fg = top pixel, bg = bottom pixel
style = Style(color=top_color, bgcolor=bot_color)
segments.append(Segment("\u2580", style))
return Strip(segments)
def _pixel_intensity(self, dist: float, sample_idx: int,
dx: float, dy: float,
radius: float, samples: list[float],
angle_idx: int, locked: bool) -> int:
"""Compute phosphor intensity (0-7) for a single pixel.
Uses pre-computed dist/sample_idx/dx/dy from LUTs (no trig per pixel).
Uses snapshot data (samples, angle_idx, locked) rather than mutable
instance state for frame-consistent rendering.
"""
# Outside the scope circle
if dist > radius + 1:
return 0
# Lock ring — outer edge glow
if locked and abs(dist - radius) < 1.5:
return 7
# Range rings at 25%, 50%, 75% radius
for ring_r in (0.25, 0.5, 0.75):
if abs(dist - radius * ring_r) < 0.7:
return 2 # dim ring
# Outer boundary ring
if abs(dist - radius) < 0.7:
return 2
# Crosshair (horizontal and vertical through center)
if abs(dx) < 0.7 and dist < radius:
return 2
if abs(dy) < 0.7 and dist < radius:
return 2
# Signal trace — map pixel angle to sample buffer
if dist < 1.0:
return 1 # center dot
if dist > radius:
return 0
# sample_idx already computed in LUT via atan2 → degrees → index
strength = samples[sample_idx]
# Signal renders as a blip at radial distance proportional to strength
signal_dist = strength * radius * 0.85 # 85% of radius at max
noise_floor_dist = radius * 0.05 # tiny noise floor ring
# Distance from the signal blip center
blip_dist = abs(dist - signal_dist)
noise_dist = abs(dist - noise_floor_dist)
# Age-based decay: how far behind the sweep beam is this angle?
age = (angle_idx - sample_idx) % self._max_samples
age_ratio = age / self._max_samples # 0 = newest, 1 = oldest
# Intensity from signal blip proximity
if strength > 0.02 and blip_dist < 2.0:
proximity = max(0.0, 1.0 - blip_dist / 2.0)
freshness = max(0.0, 1.0 - age_ratio * 1.2)
raw_intensity = proximity * freshness * strength
return max(1, min(7, int(raw_intensity * 7 + 0.5)))
# Sweep beam — the most recent angle is brightest
if age < 3:
beam_intensity = max(0.0, 1.0 - age / 3.0)
if dist < radius * 0.9:
return max(1, min(5, int(beam_intensity * 5)))
# Noise floor glow
if noise_dist < 1.0 and strength > 0:
return 1
# Fill between center and signal (faint trail)
if strength > 0.1 and dist < signal_dist and age_ratio < 0.5:
trail = max(0.0, (1.0 - age_ratio * 2) * 0.3)
if trail > 0.05:
return 1
return 0
"""Radar scope widget — P1 green phosphor CRT aesthetic.
Renders a circular radar display using half-block characters for 2x vertical
resolution. Each terminal cell becomes two pixel rows via the character
with independent fg (top) and bg (bottom) colors.
The sweep beam rotates through 360 positions. Signal strength determines
radial distance from center. Older samples decay in brightness (phosphor
persistence). Concentric range rings and a crosshair provide scale reference.
"""
import math
from collections import deque
from textual.widget import Widget
from textual.strip import Strip
from rich.segment import Segment
from rich.style import Style
# P1 green phosphor palette — 8 intensity levels from dead to peak burn
PHOSPHOR = [
"#0a0a0a", # background / dead pixel
"#0a1a0a", # very faint trace
"#0a2a0a", # dim afterglow
"#0a3a0a", # fading
"#0a4a0f", # moderate
"#0a5a10", # bright trace
"#0a6a15", # very bright
"#10ff30", # peak phosphor burn
]
PHOSPHOR_STYLES = [Style(color=c) for c in PHOSPHOR]
BG_COLOR = "#0a0a0a"
BG_STYLE = Style(color=BG_COLOR, bgcolor=BG_COLOR)
RING_COLOR = "#0a2a0a"
CROSSHAIR_COLOR = "#0a3a0a"
LOCK_RING_COLOR = "#10ff30"
class RadarScope(Widget):
"""Circular radar scope with phosphor decay and sweep beam."""
DEFAULT_CSS = """
RadarScope {
min-height: 12;
min-width: 24;
background: #0a0a0a;
}
"""
def __init__(self, max_samples: int = 360, **kwargs):
super().__init__(**kwargs)
self._samples: deque[float] = deque([0.0] * max_samples, maxlen=max_samples)
self._max_samples = max_samples
self._angle_idx = 0 # current sweep position (0..max_samples-1)
self._locked = False
# Pre-computed geometry + LUTs (rebuilt on resize)
self._cx = 0.0
self._cy = 0.0
self._radius = 0.0
self._cols = 0
self._rows = 0 # pixel rows (2x terminal rows)
# Per-pixel LUTs: distance from center, angle-to-sample index
self._dist_lut: list[list[float]] = []
self._angle_lut: list[list[int]] = []
# Per-pixel dx/dy for crosshair checks
self._dx_lut: list[list[float]] = []
self._dy_lut: list[list[float]] = []
def push(self, snr_db: float, max_snr: float = 16.0) -> None:
"""Push a new signal sample. SNR is normalized to 0.0-1.0 range."""
normalized = max(0.0, min(1.0, snr_db / max(0.1, max_snr)))
self._samples.append(normalized)
self._angle_idx = (self._angle_idx + 1) % self._max_samples
self.refresh()
def set_locked(self, locked: bool) -> None:
if locked != self._locked:
self._locked = locked
self.refresh()
def _recompute_geometry(self, width: int, height: int) -> None:
"""Recompute center, radius, and per-pixel LUTs for current dimensions.
Pre-computes dist/angle for every pixel so render_line() avoids
math.sqrt/math.atan2 per pixel per frame ~O(1) lookup instead.
"""
self._cols = width
self._rows = height * 2 # 2x vertical resolution via half-blocks
# Radius fits within both dimensions, accounting for ~2:1 terminal char aspect
rx = (width - 2) / 2.0
ry = (height * 2 - 2) / 2.0
self._radius = min(rx, ry)
cx = width / 2.0
cy = height # center in pixel rows (height * 2 / 2)
self._cx = cx
self._cy = cy
# Build LUTs for all pixel rows (height * 2) × columns
pixel_rows = height * 2
dist_lut = []
angle_lut = []
dx_lut = []
dy_lut = []
sqrt = math.sqrt
atan2 = math.atan2
degrees = math.degrees
ms = self._max_samples
for py in range(pixel_rows):
d_row = []
a_row = []
dxr = []
dyr = []
dy = py - cy
for px in range(width):
dx = px - cx
dist = sqrt(dx * dx + dy * dy)
d_row.append(dist)
dxr.append(dx)
dyr.append(dy)
if dist >= 1.0:
angle = atan2(dy, dx)
angle_deg = (degrees(angle) + 360) % 360
a_row.append(int(angle_deg / 360 * ms) % ms)
else:
a_row.append(0) # center pixel — angle irrelevant
dist_lut.append(d_row)
angle_lut.append(a_row)
dx_lut.append(dxr)
dy_lut.append(dyr)
self._dist_lut = dist_lut
self._angle_lut = angle_lut
self._dx_lut = dx_lut
self._dy_lut = dy_lut
def render_line(self, y: int) -> Strip:
"""Render one terminal row of the radar scope."""
width = self.size.width
height = self.size.height
if width < 4 or height < 4:
return Strip([Segment(" " * width, BG_STYLE)])
if self._cols != width or self._rows != height * 2:
self._recompute_geometry(width, height)
radius = self._radius
if radius < 2:
return Strip([Segment(" " * width, BG_STYLE)])
# Snapshot mutable state for consistent rendering across the frame
samples = list(self._samples)
angle_idx = self._angle_idx
locked = self._locked
# Two pixel rows per terminal row
py_top = y * 2
py_bot = y * 2 + 1
# LUT rows for this terminal row
dist_top = self._dist_lut[py_top] if py_top < len(self._dist_lut) else None
dist_bot = self._dist_lut[py_bot] if py_bot < len(self._dist_lut) else None
angle_top = self._angle_lut[py_top] if py_top < len(self._angle_lut) else None
angle_bot = self._angle_lut[py_bot] if py_bot < len(self._angle_lut) else None
dx_top = self._dx_lut[py_top] if py_top < len(self._dx_lut) else None
dx_bot = self._dx_lut[py_bot] if py_bot < len(self._dx_lut) else None
dy_top = self._dy_lut[py_top] if py_top < len(self._dy_lut) else None
dy_bot = self._dy_lut[py_bot] if py_bot < len(self._dy_lut) else None
if not dist_top or not dist_bot:
return Strip([Segment(" " * width, BG_STYLE)])
_pi = self._pixel_intensity
segments = []
for x in range(width):
top_intensity = _pi(
dist_top[x], angle_top[x], dx_top[x], dy_top[x],
radius, samples, angle_idx, locked,
)
bot_intensity = _pi(
dist_bot[x], angle_bot[x], dx_bot[x], dy_bot[x],
radius, samples, angle_idx, locked,
)
top_color = PHOSPHOR[top_intensity]
bot_color = PHOSPHOR[bot_intensity]
# ▀ char: fg = top pixel, bg = bottom pixel
style = Style(color=top_color, bgcolor=bot_color)
segments.append(Segment("\u2580", style))
return Strip(segments)
def _pixel_intensity(self, dist: float, sample_idx: int,
dx: float, dy: float,
radius: float, samples: list[float],
angle_idx: int, locked: bool) -> int:
"""Compute phosphor intensity (0-7) for a single pixel.
Uses pre-computed dist/sample_idx/dx/dy from LUTs (no trig per pixel).
Uses snapshot data (samples, angle_idx, locked) rather than mutable
instance state for frame-consistent rendering.
"""
# Outside the scope circle
if dist > radius + 1:
return 0
# Lock ring — outer edge glow
if locked and abs(dist - radius) < 1.5:
return 7
# Range rings at 25%, 50%, 75% radius
for ring_r in (0.25, 0.5, 0.75):
if abs(dist - radius * ring_r) < 0.7:
return 2 # dim ring
# Outer boundary ring
if abs(dist - radius) < 0.7:
return 2
# Crosshair (horizontal and vertical through center)
if abs(dx) < 0.7 and dist < radius:
return 2
if abs(dy) < 0.7 and dist < radius:
return 2
# Signal trace — map pixel angle to sample buffer
if dist < 1.0:
return 1 # center dot
if dist > radius:
return 0
# sample_idx already computed in LUT via atan2 → degrees → index
strength = samples[sample_idx]
# Signal renders as a blip at radial distance proportional to strength
signal_dist = strength * radius * 0.85 # 85% of radius at max
noise_floor_dist = radius * 0.05 # tiny noise floor ring
# Distance from the signal blip center
blip_dist = abs(dist - signal_dist)
noise_dist = abs(dist - noise_floor_dist)
# Age-based decay: how far behind the sweep beam is this angle?
age = (angle_idx - sample_idx) % self._max_samples
age_ratio = age / self._max_samples # 0 = newest, 1 = oldest
# Intensity from signal blip proximity
if strength > 0.02 and blip_dist < 2.0:
proximity = max(0.0, 1.0 - blip_dist / 2.0)
freshness = max(0.0, 1.0 - age_ratio * 1.2)
raw_intensity = proximity * freshness * strength
return max(1, min(7, int(raw_intensity * 7 + 0.5)))
# Sweep beam — the most recent angle is brightest
if age < 3:
beam_intensity = max(0.0, 1.0 - age / 3.0)
if dist < radius * 0.9:
return max(1, min(5, int(beam_intensity * 5)))
# Noise floor glow
if noise_dist < 1.0 and strength > 0:
return 1
# Fill between center and signal (faint trail)
if strength > 0.1 and dist < signal_dist and age_ratio < 0.5:
trail = max(0.0, (1.0 - age_ratio * 2) * 0.3)
if trail > 0.05:
return 1
return 0

View file

@ -1,120 +1,120 @@
"""Large signal strength gauge with SNR bar and lock indicator."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Static
from textual.reactive import reactive
# Bar characters for sub-block resolution
_BARS = " ▏▎▍▌▋▊▉█"
def _snr_color(snr_db: float) -> str:
"""Map SNR to a hex color: blue → cyan → green → yellow → red."""
if snr_db < 2:
return "#1565c0"
elif snr_db < 4:
return "#0097a7"
elif snr_db < 6:
return "#00bfa5"
elif snr_db < 8:
return "#00d4aa"
elif snr_db < 10:
return "#4caf50"
elif snr_db < 12:
return "#8bc34a"
elif snr_db < 14:
return "#cddc39"
elif snr_db < 16:
return "#ffc107"
else:
return "#f44336"
def _build_bar(pct: float, width: int = 40) -> str:
"""Build a Unicode block bar string with sub-character precision."""
pct = max(0.0, min(100.0, pct))
ratio = pct / 100.0
full = int(ratio * width)
remainder = (ratio * width) - full
partial = int(remainder * (len(_BARS) - 1))
bar = "" * full
if full < width:
bar += _BARS[partial]
bar += " " * (width - full - 1)
return bar
class SignalGauge(Widget):
"""Large signal strength display with SNR, power, and lock state."""
DEFAULT_CSS = """
SignalGauge {
height: auto;
padding: 1 2;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SignalGauge #gauge-header {
height: 1;
margin: 0 0 1 0;
}
SignalGauge #gauge-bar-line {
height: 1;
}
SignalGauge #gauge-details {
height: 1;
margin: 1 0 0 0;
color: #506878;
}
"""
snr_db = reactive(0.0)
snr_pct = reactive(0.0)
power_db = reactive(-40.0)
locked = reactive(False)
agc1 = reactive(0)
def compose(self) -> ComposeResult:
yield Static("", id="gauge-header")
yield Static("", id="gauge-bar-line")
yield Static("", id="gauge-details")
def watch_snr_db(self) -> None:
self._refresh_display()
def watch_locked(self) -> None:
self._refresh_display()
def update_signal(self, sig: dict) -> None:
"""Update from a signal_monitor() result dict."""
self.snr_db = sig.get("snr_db", 0.0)
self.snr_pct = sig.get("snr_pct", 0.0)
self.power_db = sig.get("power_db", -40.0)
self.locked = sig.get("locked", False)
self.agc1 = sig.get("agc1", 0)
def _refresh_display(self) -> None:
if not self.is_mounted:
return
color = _snr_color(self.snr_db)
lock_str = "[bold #00e060]LOCK[/]" if self.locked else "[#e04040]NO LOCK[/]"
header = self.query_one("#gauge-header", Static)
header.update(
f" {lock_str} "
f"[bold {color}]{self.snr_db:6.1f} dB[/] "
f"[#506878]{self.snr_pct:5.1f}%[/]"
)
bar_str = _build_bar(self.snr_pct, width=50)
bar_line = self.query_one("#gauge-bar-line", Static)
bar_line.update(f" [{color}]{bar_str}[/]")
details = self.query_one("#gauge-details", Static)
details.update(
f" Power: {self.power_db:6.1f} dB AGC: {self.agc1:5d}"
)
"""Large signal strength gauge with SNR bar and lock indicator."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Static
from textual.reactive import reactive
# Bar characters for sub-block resolution
_BARS = " ▏▎▍▌▋▊▉█"
def _snr_color(snr_db: float) -> str:
"""Map SNR to a hex color: blue → cyan → green → yellow → red."""
if snr_db < 2:
return "#1565c0"
elif snr_db < 4:
return "#0097a7"
elif snr_db < 6:
return "#00bfa5"
elif snr_db < 8:
return "#00d4aa"
elif snr_db < 10:
return "#4caf50"
elif snr_db < 12:
return "#8bc34a"
elif snr_db < 14:
return "#cddc39"
elif snr_db < 16:
return "#ffc107"
else:
return "#f44336"
def _build_bar(pct: float, width: int = 40) -> str:
"""Build a Unicode block bar string with sub-character precision."""
pct = max(0.0, min(100.0, pct))
ratio = pct / 100.0
full = int(ratio * width)
remainder = (ratio * width) - full
partial = int(remainder * (len(_BARS) - 1))
bar = "" * full
if full < width:
bar += _BARS[partial]
bar += " " * (width - full - 1)
return bar
class SignalGauge(Widget):
"""Large signal strength display with SNR, power, and lock state."""
DEFAULT_CSS = """
SignalGauge {
height: auto;
padding: 1 2;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SignalGauge #gauge-header {
height: 1;
margin: 0 0 1 0;
}
SignalGauge #gauge-bar-line {
height: 1;
}
SignalGauge #gauge-details {
height: 1;
margin: 1 0 0 0;
color: #506878;
}
"""
snr_db = reactive(0.0)
snr_pct = reactive(0.0)
power_db = reactive(-40.0)
locked = reactive(False)
agc1 = reactive(0)
def compose(self) -> ComposeResult:
yield Static("", id="gauge-header")
yield Static("", id="gauge-bar-line")
yield Static("", id="gauge-details")
def watch_snr_db(self) -> None:
self._refresh_display()
def watch_locked(self) -> None:
self._refresh_display()
def update_signal(self, sig: dict) -> None:
"""Update from a signal_monitor() result dict."""
self.snr_db = sig.get("snr_db", 0.0)
self.snr_pct = sig.get("snr_pct", 0.0)
self.power_db = sig.get("power_db", -40.0)
self.locked = sig.get("locked", False)
self.agc1 = sig.get("agc1", 0)
def _refresh_display(self) -> None:
if not self.is_mounted:
return
color = _snr_color(self.snr_db)
lock_str = "[bold #00e060]LOCK[/]" if self.locked else "[#e04040]NO LOCK[/]"
header = self.query_one("#gauge-header", Static)
header.update(
f" {lock_str} "
f"[bold {color}]{self.snr_db:6.1f} dB[/] "
f"[#506878]{self.snr_pct:5.1f}%[/]"
)
bar_str = _build_bar(self.snr_pct, width=50)
bar_line = self.query_one("#gauge-bar-line", Static)
bar_line.update(f" [{color}]{bar_str}[/]")
details = self.query_one("#gauge-details", Static)
details.update(
f" Power: {self.power_db:6.1f} dB AGC: {self.agc1:5d}"
)

View file

@ -1,71 +1,71 @@
"""Rolling sparkline time series widget using Unicode spark characters."""
from collections import deque
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
_SPARKS = "▁▂▃▄▅▆▇█"
class SparklineWidget(Widget):
"""Rolling time-series display using Unicode block characters."""
DEFAULT_CSS = """
SparklineWidget {
height: 3;
padding: 0 1;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SparklineWidget #spark-label {
height: 1;
color: #506878;
}
SparklineWidget #spark-line {
height: 1;
}
"""
def __init__(self, title: str = "History", max_width: int = 80,
color: str = "#00d4aa", **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_width = max_width
self._color = color
self._values: deque[float] = deque(maxlen=max_width)
def compose(self) -> ComposeResult:
yield Static(f"[#506878]{self._title}[/]", id="spark-label")
yield Static("", id="spark-line")
def push(self, value: float) -> None:
"""Add a new data point and refresh the display."""
self._values.append(value)
self._refresh()
def clear(self) -> None:
self._values.clear()
if self.is_mounted:
self.query_one("#spark-line", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._values:
return
vals = list(self._values)
mn = min(vals)
mx = max(vals)
rng = mx - mn if mx != mn else 1.0
chars = []
for v in vals:
idx = int((v - mn) / rng * (len(_SPARKS) - 1))
idx = max(0, min(len(_SPARKS) - 1, idx))
chars.append(_SPARKS[idx])
spark_str = "".join(chars)
line = self.query_one("#spark-line", Static)
line.update(f"[{self._color}]{spark_str}[/] [{mn:.1f} .. {mx:.1f}]")
"""Rolling sparkline time series widget using Unicode spark characters."""
from collections import deque
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
_SPARKS = "▁▂▃▄▅▆▇█"
class SparklineWidget(Widget):
"""Rolling time-series display using Unicode block characters."""
DEFAULT_CSS = """
SparklineWidget {
height: 3;
padding: 0 1;
background: #0e1420;
border: round #1a2a3a;
margin: 0 0 1 0;
}
SparklineWidget #spark-label {
height: 1;
color: #506878;
}
SparklineWidget #spark-line {
height: 1;
}
"""
def __init__(self, title: str = "History", max_width: int = 80,
color: str = "#00d4aa", **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_width = max_width
self._color = color
self._values: deque[float] = deque(maxlen=max_width)
def compose(self) -> ComposeResult:
yield Static(f"[#506878]{self._title}[/]", id="spark-label")
yield Static("", id="spark-line")
def push(self, value: float) -> None:
"""Add a new data point and refresh the display."""
self._values.append(value)
self._refresh()
def clear(self) -> None:
self._values.clear()
if self.is_mounted:
self.query_one("#spark-line", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._values:
return
vals = list(self._values)
mn = min(vals)
mx = max(vals)
rng = mx - mn if mx != mn else 1.0
chars = []
for v in vals:
idx = int((v - mn) / rng * (len(_SPARKS) - 1))
idx = max(0, min(len(_SPARKS) - 1, idx))
chars.append(_SPARKS[idx])
spark_str = "".join(chars)
line = self.query_one("#spark-line", Static)
line.update(f"[{self._color}]{spark_str}[/] [{mn:.1f} .. {mx:.1f}]")

View file

@ -1,155 +1,155 @@
"""Terminal-native spectrum plot using Unicode block characters and Rich markup.
Renders a horizontal bar chart where each frequency bin gets a colored bar
proportional to its power level. The color gradient goes from cold (blue)
to hot (red), same concept as the CLI tool's WATERFALL_COLORS but using
Rich style strings instead of raw ANSI escapes.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from skywalker_lib import detect_peaks, if_to_rf
# Sub-block characters for fractional bar width
_BARS = " ▏▎▍▌▋▊▉█"
# Power-to-color gradient (16 steps: blue → cyan → green → yellow → red)
_POWER_COLORS = [
"#1a237e", # dark blue (weakest)
"#1565c0",
"#0277bd",
"#00838f",
"#00897b", # teal
"#2e7d32", # green
"#558b2f",
"#9e9d24",
"#f9a825", # yellow
"#ff8f00",
"#ef6c00", # orange
"#e65100",
"#d84315",
"#c62828", # red
"#b71c1c",
"#880e0e", # dark red (strongest)
]
def _power_to_color(power_db: float, floor: float, ceiling: float) -> str:
"""Map a power value to a color from the gradient."""
if ceiling == floor:
return _POWER_COLORS[len(_POWER_COLORS) // 2]
ratio = (power_db - floor) / (ceiling - floor)
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_POWER_COLORS) - 1))
return _POWER_COLORS[idx]
class SpectrumPlot(Widget):
"""Bar-chart spectrum display rendered with Unicode blocks and Rich styles."""
DEFAULT_CSS = """
SpectrumPlot {
height: 1fr;
min-height: 12;
background: #0a0a12;
padding: 0 1;
}
SpectrumPlot #spectrum-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
SpectrumPlot #spectrum-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Spectrum", bar_width: int = 40,
lnb_lo: float = 0.0, **kwargs):
super().__init__(**kwargs)
self._title = title
self._bar_width = bar_width
self._lnb_lo = lnb_lo
self._freqs: list[float] = []
self._powers: list[float] = []
self._results: list[dict] = []
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="spectrum-title")
yield VerticalScroll(Static("", id="spectrum-lines"), id="spectrum-body")
def update_data(self, freqs: list[float], powers: list[float],
results: list[dict] | None = None, lnb_lo: float | None = None):
"""Update with new sweep data and redraw."""
self._freqs = freqs
self._powers = powers
self._results = results or [{} for _ in freqs]
if lnb_lo is not None:
self._lnb_lo = lnb_lo
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted or not self._freqs:
return
p_min = min(self._powers)
p_max = max(self._powers)
p_range = p_max - p_min if p_max != p_min else 1.0
# Detect peaks for markers
peaks_set = set()
peaks = detect_peaks(self._freqs, self._powers, threshold_db=3.0)
for _f, _p, idx in peaks:
peaks_set.add(idx)
lines = []
for i, (f, p) in enumerate(zip(self._freqs, self._powers)):
# Frequency label (RF or IF)
if self._lnb_lo > 0:
label = f"{if_to_rf(f, self._lnb_lo):7.0f}"
else:
label = f"{f:7.1f}"
# Bar
ratio = max(0.0, min(1.0, (p - p_min) / p_range))
full = int(ratio * self._bar_width)
remainder = (ratio * self._bar_width) - full
partial = int(remainder * (len(_BARS) - 1))
color = _power_to_color(p, p_min, p_max)
bar = "" * full
if full < self._bar_width:
bar += _BARS[partial]
bar += " " * (self._bar_width - full - 1)
locked = self._results[i].get("locked", False) if i < len(self._results) else False
lock_mark = " [bold #00e060]*[/]" if locked else ""
peak_mark = " [bold #f44336]^[/]" if i in peaks_set else ""
lines.append(
f"[#506878]{label}[/] [{color}]{bar}[/] [#7090a8]{p:6.1f}[/]{lock_mark}{peak_mark}"
)
# Peak summary at bottom
if peaks:
lines.append("")
lines.append(f"[#00d4aa bold]Peaks ({len(peaks)}):[/]")
for freq, pwr, idx in peaks:
if self._lnb_lo > 0:
fl = f"{if_to_rf(freq, self._lnb_lo):.0f} MHz RF"
else:
fl = f"{freq:.1f} MHz"
locked = self._results[idx].get("locked", False) if idx < len(self._results) else False
lock_s = " [bold #00e060]LOCKED[/]" if locked else ""
lines.append(f" [#c8d0d8]{fl} {pwr:.1f} dB{lock_s}[/]")
body = self.query_one("#spectrum-lines", Static)
body.update("\n".join(lines))
"""Terminal-native spectrum plot using Unicode block characters and Rich markup.
Renders a horizontal bar chart where each frequency bin gets a colored bar
proportional to its power level. The color gradient goes from cold (blue)
to hot (red), same concept as the CLI tool's WATERFALL_COLORS but using
Rich style strings instead of raw ANSI escapes.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "tools"))
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
from skywalker_lib import detect_peaks, if_to_rf
# Sub-block characters for fractional bar width
_BARS = " ▏▎▍▌▋▊▉█"
# Power-to-color gradient (16 steps: blue → cyan → green → yellow → red)
_POWER_COLORS = [
"#1a237e", # dark blue (weakest)
"#1565c0",
"#0277bd",
"#00838f",
"#00897b", # teal
"#2e7d32", # green
"#558b2f",
"#9e9d24",
"#f9a825", # yellow
"#ff8f00",
"#ef6c00", # orange
"#e65100",
"#d84315",
"#c62828", # red
"#b71c1c",
"#880e0e", # dark red (strongest)
]
def _power_to_color(power_db: float, floor: float, ceiling: float) -> str:
"""Map a power value to a color from the gradient."""
if ceiling == floor:
return _POWER_COLORS[len(_POWER_COLORS) // 2]
ratio = (power_db - floor) / (ceiling - floor)
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_POWER_COLORS) - 1))
return _POWER_COLORS[idx]
class SpectrumPlot(Widget):
"""Bar-chart spectrum display rendered with Unicode blocks and Rich styles."""
DEFAULT_CSS = """
SpectrumPlot {
height: 1fr;
min-height: 12;
background: #0a0a12;
padding: 0 1;
}
SpectrumPlot #spectrum-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
SpectrumPlot #spectrum-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Spectrum", bar_width: int = 40,
lnb_lo: float = 0.0, **kwargs):
super().__init__(**kwargs)
self._title = title
self._bar_width = bar_width
self._lnb_lo = lnb_lo
self._freqs: list[float] = []
self._powers: list[float] = []
self._results: list[dict] = []
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="spectrum-title")
yield VerticalScroll(Static("", id="spectrum-lines"), id="spectrum-body")
def update_data(self, freqs: list[float], powers: list[float],
results: list[dict] | None = None, lnb_lo: float | None = None):
"""Update with new sweep data and redraw."""
self._freqs = freqs
self._powers = powers
self._results = results or [{} for _ in freqs]
if lnb_lo is not None:
self._lnb_lo = lnb_lo
self._refresh()
def _refresh(self) -> None:
if not self.is_mounted or not self._freqs:
return
p_min = min(self._powers)
p_max = max(self._powers)
p_range = p_max - p_min if p_max != p_min else 1.0
# Detect peaks for markers
peaks_set = set()
peaks = detect_peaks(self._freqs, self._powers, threshold_db=3.0)
for _f, _p, idx in peaks:
peaks_set.add(idx)
lines = []
for i, (f, p) in enumerate(zip(self._freqs, self._powers)):
# Frequency label (RF or IF)
if self._lnb_lo > 0:
label = f"{if_to_rf(f, self._lnb_lo):7.0f}"
else:
label = f"{f:7.1f}"
# Bar
ratio = max(0.0, min(1.0, (p - p_min) / p_range))
full = int(ratio * self._bar_width)
remainder = (ratio * self._bar_width) - full
partial = int(remainder * (len(_BARS) - 1))
color = _power_to_color(p, p_min, p_max)
bar = "" * full
if full < self._bar_width:
bar += _BARS[partial]
bar += " " * (self._bar_width - full - 1)
locked = self._results[i].get("locked", False) if i < len(self._results) else False
lock_mark = " [bold #00e060]*[/]" if locked else ""
peak_mark = " [bold #f44336]^[/]" if i in peaks_set else ""
lines.append(
f"[#506878]{label}[/] [{color}]{bar}[/] [#7090a8]{p:6.1f}[/]{lock_mark}{peak_mark}"
)
# Peak summary at bottom
if peaks:
lines.append("")
lines.append(f"[#00d4aa bold]Peaks ({len(peaks)}):[/]")
for freq, pwr, idx in peaks:
if self._lnb_lo > 0:
fl = f"{if_to_rf(freq, self._lnb_lo):.0f} MHz RF"
else:
fl = f"{freq:.1f} MHz"
locked = self._results[idx].get("locked", False) if idx < len(self._results) else False
lock_s = " [bold #00e060]LOCKED[/]" if locked else ""
lines.append(f" [#c8d0d8]{fl} {pwr:.1f} dB{lock_s}[/]")
body = self.query_one("#spectrum-lines", Static)
body.update("\n".join(lines))

View file

@ -1,65 +1,65 @@
"""Device status bar — connection state, firmware version, config bits."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Label
from skywalker_lib import format_config_bits
class DeviceStatusBar(Widget):
"""Bottom status bar showing device connection and configuration."""
DEFAULT_CSS = """
DeviceStatusBar {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
layout: horizontal;
}
DeviceStatusBar Label {
width: auto;
margin: 1 2 0 0;
}
"""
def __init__(self, bridge=None):
super().__init__(id="device-status")
self._bridge = bridge
def compose(self) -> ComposeResult:
yield Label("", id="status-conn")
yield Label("", id="status-fw")
yield Label("", id="status-config")
def update_status(self, bridge=None):
if bridge is not None:
self._bridge = bridge
if self._bridge is None:
return
conn_label = self.query_one("#status-conn", Label)
fw_label = self.query_one("#status-fw", Label)
config_label = self.query_one("#status-config", Label)
if self._bridge.is_demo:
conn_label.update("[bold #e8a020]DEMO[/]")
else:
conn_label.update("[bold #00d4aa]CONNECTED[/]")
try:
fw = self._bridge.get_fw_version()
fw_label.update(f"[#506878]FW:[/] [#c8d0d8]{fw['version']}[/]")
except Exception:
fw_label.update("[#506878]FW:[/] [#e04040]error[/]")
try:
config = self._bridge.get_config()
bits = format_config_bits(config)
active = [name for name, is_set in bits if is_set]
config_str = " | ".join(active) if active else "idle"
config_label.update(f"[#506878]Config:[/] [#7090a8]{config_str}[/]")
except Exception:
config_label.update("[#506878]Config:[/] [#e04040]error[/]")
"""Device status bar — connection state, firmware version, config bits."""
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Label
from skywalker_lib import format_config_bits
class DeviceStatusBar(Widget):
"""Bottom status bar showing device connection and configuration."""
DEFAULT_CSS = """
DeviceStatusBar {
height: 3;
background: #0e1420;
border-top: solid #1a2a3a;
padding: 0 1;
dock: bottom;
layout: horizontal;
}
DeviceStatusBar Label {
width: auto;
margin: 1 2 0 0;
}
"""
def __init__(self, bridge=None):
super().__init__(id="device-status")
self._bridge = bridge
def compose(self) -> ComposeResult:
yield Label("", id="status-conn")
yield Label("", id="status-fw")
yield Label("", id="status-config")
def update_status(self, bridge=None):
if bridge is not None:
self._bridge = bridge
if self._bridge is None:
return
conn_label = self.query_one("#status-conn", Label)
fw_label = self.query_one("#status-fw", Label)
config_label = self.query_one("#status-config", Label)
if self._bridge.is_demo:
conn_label.update("[bold #e8a020]DEMO[/]")
else:
conn_label.update("[bold #00d4aa]CONNECTED[/]")
try:
fw = self._bridge.get_fw_version()
fw_label.update(f"[#506878]FW:[/] [#c8d0d8]{fw['version']}[/]")
except Exception:
fw_label.update("[#506878]FW:[/] [#e04040]error[/]")
try:
config = self._bridge.get_config()
bits = format_config_bits(config)
active = [name for name, is_set in bits if is_set]
config_str = " | ".join(active) if active else "idle"
config_label.update(f"[#506878]Config:[/] [#7090a8]{config_str}[/]")
except Exception:
config_label.update("[#506878]Config:[/] [#e04040]error[/]")

View file

@ -1,98 +1,98 @@
"""Rolling waterfall display — each row is one sweep, color = power level.
The waterfall auto-scrolls: new sweeps appear at the top, older rows shift down.
Uses Rich markup with the same 16-color power gradient as the spectrum plot.
"""
from collections import deque
from datetime import datetime
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
# Same gradient as spectrum_plot
_WATERFALL_COLORS = [
"#1a237e", "#1565c0", "#0277bd", "#00838f",
"#00897b", "#2e7d32", "#558b2f", "#9e9d24",
"#f9a825", "#ff8f00", "#ef6c00", "#e65100",
"#d84315", "#c62828", "#b71c1c", "#880e0e",
]
class WaterfallDisplay(Widget):
"""Scrolling waterfall of spectrum sweeps."""
DEFAULT_CSS = """
WaterfallDisplay {
height: 1fr;
min-height: 8;
background: #0a0a12;
padding: 0 1;
}
WaterfallDisplay #waterfall-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
WaterfallDisplay #waterfall-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Waterfall", max_rows: int = 50, **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_rows = max_rows
self._rows: deque[tuple[str, list[float]]] = deque(maxlen=max_rows)
self._global_min: float = -40.0
self._global_max: float = 0.0
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="waterfall-title")
yield VerticalScroll(Static("", id="waterfall-lines"), id="waterfall-body")
def add_sweep(self, powers: list[float]) -> None:
"""Add a new sweep row and refresh."""
ts = datetime.now().strftime("%H:%M:%S")
self._rows.appendleft((ts, list(powers)))
# Update global range for consistent coloring
if powers:
self._global_min = min(self._global_min, min(powers))
self._global_max = max(self._global_max, max(powers))
self._refresh()
def clear(self) -> None:
self._rows.clear()
self._global_min = -40.0
self._global_max = 0.0
if self.is_mounted:
self.query_one("#waterfall-lines", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._rows:
return
rng = self._global_max - self._global_min
if rng == 0:
rng = 1.0
lines = []
for ts, powers in self._rows:
chars = []
for p in powers:
ratio = (p - self._global_min) / rng
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_WATERFALL_COLORS) - 1))
color = _WATERFALL_COLORS[idx]
chars.append(f"[{color}]█[/]")
line = "".join(chars)
lines.append(f"[#506878]{ts}[/] {line}")
body = self.query_one("#waterfall-lines", Static)
body.update("\n".join(lines))
"""Rolling waterfall display — each row is one sweep, color = power level.
The waterfall auto-scrolls: new sweeps appear at the top, older rows shift down.
Uses Rich markup with the same 16-color power gradient as the spectrum plot.
"""
from collections import deque
from datetime import datetime
from textual.widget import Widget
from textual.widgets import Static
from textual.app import ComposeResult
from textual.containers import VerticalScroll
# Same gradient as spectrum_plot
_WATERFALL_COLORS = [
"#1a237e", "#1565c0", "#0277bd", "#00838f",
"#00897b", "#2e7d32", "#558b2f", "#9e9d24",
"#f9a825", "#ff8f00", "#ef6c00", "#e65100",
"#d84315", "#c62828", "#b71c1c", "#880e0e",
]
class WaterfallDisplay(Widget):
"""Scrolling waterfall of spectrum sweeps."""
DEFAULT_CSS = """
WaterfallDisplay {
height: 1fr;
min-height: 8;
background: #0a0a12;
padding: 0 1;
}
WaterfallDisplay #waterfall-title {
height: 1;
color: #00d4aa;
text-style: bold;
}
WaterfallDisplay #waterfall-body {
height: 1fr;
}
"""
def __init__(self, title: str = "Waterfall", max_rows: int = 50, **kwargs):
super().__init__(**kwargs)
self._title = title
self._max_rows = max_rows
self._rows: deque[tuple[str, list[float]]] = deque(maxlen=max_rows)
self._global_min: float = -40.0
self._global_max: float = 0.0
def compose(self) -> ComposeResult:
yield Static(f"[#00d4aa bold]{self._title}[/]", id="waterfall-title")
yield VerticalScroll(Static("", id="waterfall-lines"), id="waterfall-body")
def add_sweep(self, powers: list[float]) -> None:
"""Add a new sweep row and refresh."""
ts = datetime.now().strftime("%H:%M:%S")
self._rows.appendleft((ts, list(powers)))
# Update global range for consistent coloring
if powers:
self._global_min = min(self._global_min, min(powers))
self._global_max = max(self._global_max, max(powers))
self._refresh()
def clear(self) -> None:
self._rows.clear()
self._global_min = -40.0
self._global_max = 0.0
if self.is_mounted:
self.query_one("#waterfall-lines", Static).update("")
def _refresh(self) -> None:
if not self.is_mounted or not self._rows:
return
rng = self._global_max - self._global_min
if rng == 0:
rng = 1.0
lines = []
for ts, powers in self._rows:
chars = []
for p in powers:
ratio = (p - self._global_min) / rng
ratio = max(0.0, min(1.0, ratio))
idx = int(ratio * (len(_WATERFALL_COLORS) - 1))
color = _WATERFALL_COLORS[idx]
chars.append(f"[{color}]█[/]")
line = "".join(chars)
lines.append(f"[#506878]{ts}[/] {line}")
body = self.query_one("#waterfall-lines", Static)
body.update("\n".join(lines))

View file

@ -1,192 +1,192 @@
"""Tests for the radar scope widget — LUT geometry and pixel intensity."""
from skywalker_tui.widgets.radar_scope import RadarScope
class TestRadarGeometry:
"""LUT pre-computation and geometry tests."""
def _make_scope(self, width=40, height=20):
scope = RadarScope()
scope._recompute_geometry(width, height)
return scope
def test_lut_dimensions(self):
scope = self._make_scope(40, 20)
# pixel rows = terminal rows * 2
assert len(scope._dist_lut) == 40
assert len(scope._angle_lut) == 40
assert len(scope._dx_lut) == 40
assert len(scope._dy_lut) == 40
# each row has `width` columns
assert len(scope._dist_lut[0]) == 40
assert len(scope._angle_lut[0]) == 40
def test_center_pixel_distance_near_zero(self):
scope = self._make_scope(40, 20)
# center = (20, 20) in pixel coords
cx_int = int(scope._cx)
cy_int = int(scope._cy)
dist = scope._dist_lut[cy_int][cx_int]
assert dist < 1.0, f"Center pixel distance should be ~0, got {dist}"
def test_corner_pixel_outside_circle(self):
scope = self._make_scope(40, 20)
dist = scope._dist_lut[0][0]
assert dist > scope._radius, "Corner should be outside the radar circle"
def test_radius_fits_within_bounds(self):
scope = self._make_scope(60, 30)
# radius should be ≤ half the smaller dimension
assert scope._radius <= 30 # half of width
assert scope._radius <= 29 # half of pixel height (60) - 1
def test_recompute_updates_on_resize(self):
scope = self._make_scope(40, 20)
r1 = scope._radius
scope._recompute_geometry(80, 40)
r2 = scope._radius
assert r2 > r1, "Larger terminal should give larger radius"
class TestPixelIntensity:
"""Tests for _pixel_intensity with pre-computed LUT values."""
def _make_scope(self, width=40, height=20):
scope = RadarScope()
scope._recompute_geometry(width, height)
return scope
def test_outside_circle_returns_zero(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius + 5,
sample_idx=0, dx=20.0, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 0
def test_center_on_crosshair(self):
"""At exact center, crosshair check (abs(dx) < 0.7) fires before center dot."""
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=0.5, sample_idx=0, dx=0.0, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 2 # crosshair overlaps center
def test_center_off_crosshair(self):
"""Near center but off crosshair axes → center dot (1)."""
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=0.8, sample_idx=45, dx=0.8, dy=0.8,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 1 # center dot, off crosshair axes
def test_lock_ring_at_edge(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=True,
)
assert result == 7 # peak phosphor for lock ring
def test_no_lock_ring_when_unlocked(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
# Should be boundary ring (2), not lock ring (7)
assert result != 7
def test_crosshair_on_vertical(self):
scope = self._make_scope()
samples = [0.0] * 360
# Point on the vertical crosshair (dx near 0, inside circle)
result = scope._pixel_intensity(
dist=scope._radius * 0.5,
sample_idx=90, dx=0.0, dy=scope._radius * 0.5,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 2 # crosshair intensity
def test_strong_signal_blip_near_sweep(self):
scope = self._make_scope()
samples = [0.0] * 360
# strength 0.47 → blip at 0.47 * r * 0.85 = 0.40*r
# Clear of range rings at 0.25r, 0.50r, 0.75r (nearest gap: 0.15r ≈ 2.85px)
samples[180] = 0.47
signal_dist = 0.47 * scope._radius * 0.85
# Verify we're not on a range ring
r = scope._radius
for ring_r in (0.25, 0.5, 0.75):
assert abs(signal_dist - r * ring_r) > 0.7, (
f"Signal blip at {signal_dist:.2f} overlaps range ring at {r * ring_r:.2f}"
)
result = scope._pixel_intensity(
dist=signal_dist,
sample_idx=180, dx=5.0, dy=5.0,
radius=scope._radius, samples=samples,
angle_idx=180, locked=False,
)
# Newest sample at sweep position should be visible
assert result >= 3
def test_old_signal_decays(self):
scope = self._make_scope()
samples = [0.0] * 360
samples[0] = 0.8
signal_dist = 0.8 * scope._radius * 0.85
# Sample at index 0, sweep beam at index 300 → age = 300
result = scope._pixel_intensity(
dist=signal_dist,
sample_idx=0, dx=5.0, dy=5.0,
radius=scope._radius, samples=samples,
angle_idx=300, locked=False,
)
# Old sample should be dim or invisible
assert result <= 2
class TestRadarPush:
"""Tests for the push() method and normalization."""
def test_push_normalizes_to_range(self):
scope = RadarScope(max_samples=10)
scope.push(8.0, max_snr=16.0)
assert scope._samples[-1] == 0.5
def test_push_clamps_above_max(self):
scope = RadarScope(max_samples=10)
scope.push(20.0, max_snr=16.0)
assert scope._samples[-1] == 1.0
def test_push_clamps_below_zero(self):
scope = RadarScope(max_samples=10)
scope.push(-5.0, max_snr=16.0)
assert scope._samples[-1] == 0.0
def test_angle_index_wraps(self):
scope = RadarScope(max_samples=4)
for _ in range(5):
scope.push(1.0)
assert scope._angle_idx == 1 # 5 % 4 = 1
def test_set_locked(self):
scope = RadarScope()
assert scope._locked is False
scope.set_locked(True)
assert scope._locked is True
"""Tests for the radar scope widget — LUT geometry and pixel intensity."""
from skywalker_tui.widgets.radar_scope import RadarScope
class TestRadarGeometry:
"""LUT pre-computation and geometry tests."""
def _make_scope(self, width=40, height=20):
scope = RadarScope()
scope._recompute_geometry(width, height)
return scope
def test_lut_dimensions(self):
scope = self._make_scope(40, 20)
# pixel rows = terminal rows * 2
assert len(scope._dist_lut) == 40
assert len(scope._angle_lut) == 40
assert len(scope._dx_lut) == 40
assert len(scope._dy_lut) == 40
# each row has `width` columns
assert len(scope._dist_lut[0]) == 40
assert len(scope._angle_lut[0]) == 40
def test_center_pixel_distance_near_zero(self):
scope = self._make_scope(40, 20)
# center = (20, 20) in pixel coords
cx_int = int(scope._cx)
cy_int = int(scope._cy)
dist = scope._dist_lut[cy_int][cx_int]
assert dist < 1.0, f"Center pixel distance should be ~0, got {dist}"
def test_corner_pixel_outside_circle(self):
scope = self._make_scope(40, 20)
dist = scope._dist_lut[0][0]
assert dist > scope._radius, "Corner should be outside the radar circle"
def test_radius_fits_within_bounds(self):
scope = self._make_scope(60, 30)
# radius should be ≤ half the smaller dimension
assert scope._radius <= 30 # half of width
assert scope._radius <= 29 # half of pixel height (60) - 1
def test_recompute_updates_on_resize(self):
scope = self._make_scope(40, 20)
r1 = scope._radius
scope._recompute_geometry(80, 40)
r2 = scope._radius
assert r2 > r1, "Larger terminal should give larger radius"
class TestPixelIntensity:
"""Tests for _pixel_intensity with pre-computed LUT values."""
def _make_scope(self, width=40, height=20):
scope = RadarScope()
scope._recompute_geometry(width, height)
return scope
def test_outside_circle_returns_zero(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius + 5,
sample_idx=0, dx=20.0, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 0
def test_center_on_crosshair(self):
"""At exact center, crosshair check (abs(dx) < 0.7) fires before center dot."""
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=0.5, sample_idx=0, dx=0.0, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 2 # crosshair overlaps center
def test_center_off_crosshair(self):
"""Near center but off crosshair axes → center dot (1)."""
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=0.8, sample_idx=45, dx=0.8, dy=0.8,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 1 # center dot, off crosshair axes
def test_lock_ring_at_edge(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=True,
)
assert result == 7 # peak phosphor for lock ring
def test_no_lock_ring_when_unlocked(self):
scope = self._make_scope()
samples = [0.0] * 360
result = scope._pixel_intensity(
dist=scope._radius, sample_idx=0, dx=scope._radius, dy=0.0,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
# Should be boundary ring (2), not lock ring (7)
assert result != 7
def test_crosshair_on_vertical(self):
scope = self._make_scope()
samples = [0.0] * 360
# Point on the vertical crosshair (dx near 0, inside circle)
result = scope._pixel_intensity(
dist=scope._radius * 0.5,
sample_idx=90, dx=0.0, dy=scope._radius * 0.5,
radius=scope._radius, samples=samples,
angle_idx=0, locked=False,
)
assert result == 2 # crosshair intensity
def test_strong_signal_blip_near_sweep(self):
scope = self._make_scope()
samples = [0.0] * 360
# strength 0.47 → blip at 0.47 * r * 0.85 = 0.40*r
# Clear of range rings at 0.25r, 0.50r, 0.75r (nearest gap: 0.15r ≈ 2.85px)
samples[180] = 0.47
signal_dist = 0.47 * scope._radius * 0.85
# Verify we're not on a range ring
r = scope._radius
for ring_r in (0.25, 0.5, 0.75):
assert abs(signal_dist - r * ring_r) > 0.7, (
f"Signal blip at {signal_dist:.2f} overlaps range ring at {r * ring_r:.2f}"
)
result = scope._pixel_intensity(
dist=signal_dist,
sample_idx=180, dx=5.0, dy=5.0,
radius=scope._radius, samples=samples,
angle_idx=180, locked=False,
)
# Newest sample at sweep position should be visible
assert result >= 3
def test_old_signal_decays(self):
scope = self._make_scope()
samples = [0.0] * 360
samples[0] = 0.8
signal_dist = 0.8 * scope._radius * 0.85
# Sample at index 0, sweep beam at index 300 → age = 300
result = scope._pixel_intensity(
dist=signal_dist,
sample_idx=0, dx=5.0, dy=5.0,
radius=scope._radius, samples=samples,
angle_idx=300, locked=False,
)
# Old sample should be dim or invisible
assert result <= 2
class TestRadarPush:
"""Tests for the push() method and normalization."""
def test_push_normalizes_to_range(self):
scope = RadarScope(max_samples=10)
scope.push(8.0, max_snr=16.0)
assert scope._samples[-1] == 0.5
def test_push_clamps_above_max(self):
scope = RadarScope(max_samples=10)
scope.push(20.0, max_snr=16.0)
assert scope._samples[-1] == 1.0
def test_push_clamps_below_zero(self):
scope = RadarScope(max_samples=10)
scope.push(-5.0, max_snr=16.0)
assert scope._samples[-1] == 0.0
def test_angle_index_wraps(self):
scope = RadarScope(max_samples=4)
for _ in range(5):
scope.push(1.0)
assert scope._angle_idx == 1 # 5 % 4 = 1
def test_set_locked(self):
scope = RadarScope()
assert scope._locked is False
scope.set_locked(True)
assert scope._locked is True

View file

@ -1,48 +1,48 @@
"""Tests for the splash screen art catalog and selection."""
from skywalker_tui.screens.splash import SplashScreen, ASSETS_DIR, ART_CATALOG
class TestSplashArtCatalog:
"""Verify bundled pre-baked .ans art assets exist and catalog is consistent."""
def test_assets_dir_exists(self):
assert ASSETS_DIR.is_dir(), f"Assets dir missing: {ASSETS_DIR}"
def test_all_catalog_entries_have_ans_files(self):
missing = []
for stem, artist, title in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if not path.exists():
missing.append(f"{stem}.ans")
assert not missing, f"Missing pre-baked .ans files: {missing}"
def test_catalog_has_entries(self):
assert len(ART_CATALOG) >= 1
def test_ans_files_not_empty(self):
for stem, _, _ in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if path.exists():
assert path.stat().st_size > 0, f"{stem}.ans is empty"
def test_ans_files_contain_ansi_escapes(self):
"""Pre-baked files should contain ANSI color escape sequences."""
for stem, _, _ in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if path.exists():
content = path.read_text()
assert "\033[" in content, f"{stem}.ans has no ANSI escapes"
assert "\u2580" in content, f"{stem}.ans has no half-block chars"
class TestSplashScreenInit:
"""Test SplashScreen construction (no app needed)."""
def test_selects_art_on_init(self):
screen = SplashScreen()
assert screen._ans_path is not None or len(ART_CATALOG) == 0
if screen._ans_path:
assert screen._ans_path.exists()
assert screen._artist != ""
assert screen._title != ""
"""Tests for the splash screen art catalog and selection."""
from skywalker_tui.screens.splash import SplashScreen, ASSETS_DIR, ART_CATALOG
class TestSplashArtCatalog:
"""Verify bundled pre-baked .ans art assets exist and catalog is consistent."""
def test_assets_dir_exists(self):
assert ASSETS_DIR.is_dir(), f"Assets dir missing: {ASSETS_DIR}"
def test_all_catalog_entries_have_ans_files(self):
missing = []
for stem, artist, title in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if not path.exists():
missing.append(f"{stem}.ans")
assert not missing, f"Missing pre-baked .ans files: {missing}"
def test_catalog_has_entries(self):
assert len(ART_CATALOG) >= 1
def test_ans_files_not_empty(self):
for stem, _, _ in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if path.exists():
assert path.stat().st_size > 0, f"{stem}.ans is empty"
def test_ans_files_contain_ansi_escapes(self):
"""Pre-baked files should contain ANSI color escape sequences."""
for stem, _, _ in ART_CATALOG:
path = ASSETS_DIR / f"{stem}.ans"
if path.exists():
content = path.read_text()
assert "\033[" in content, f"{stem}.ans has no ANSI escapes"
assert "\u2580" in content, f"{stem}.ans has no half-block chars"
class TestSplashScreenInit:
"""Test SplashScreen construction (no app needed)."""
def test_selects_art_on_init(self):
screen = SplashScreen()
assert screen._ans_path is not None or len(ART_CATALOG) == 0
if screen._ans_path:
assert screen._ans_path.exists()
assert screen._artist != ""
assert screen._title != ""

View file

@ -1,108 +1,108 @@
"""Tests for the stateful telnet IAC sequence stripper."""
from skywalker_tui.screens.starwars import _TelnetStripper
class TestTelnetStripper:
"""Edge cases for IAC parsing across chunk boundaries."""
def test_plain_text_passthrough(self):
s = _TelnetStripper()
assert s.feed(b"hello world") == b"hello world"
def test_strips_will_command(self):
# IAC WILL ECHO = FF FB 01
s = _TelnetStripper()
assert s.feed(b"\xff\xfb\x01hello") == b"hello"
def test_strips_wont_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfc\x03data") == b"data"
def test_strips_do_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfd\x01data") == b"data"
def test_strips_dont_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfe\x01data") == b"data"
def test_escaped_0xff(self):
"""Doubled 0xFF = literal 0xFF byte in content."""
s = _TelnetStripper()
assert s.feed(b"\xff\xff") == b"\xff"
def test_sub_negotiation(self):
# IAC SB 0x01 ... IAC SE = FF FA 01 xx xx FF F0
s = _TelnetStripper()
data = b"before\xff\xfa\x01\x00\x00\xff\xf0after"
assert s.feed(data) == b"beforeafter"
def test_split_iac_across_chunks(self):
"""IAC command split: FF in chunk 1, FB 01 in chunk 2."""
s = _TelnetStripper()
out1 = s.feed(b"hello\xff")
out2 = s.feed(b"\xfb\x01world")
assert out1 == b"hello"
assert out2 == b"world"
def test_split_will_at_boundary(self):
"""IAC WILL split: FF FB in chunk 1, option byte in chunk 2."""
s = _TelnetStripper()
out1 = s.feed(b"aaa\xff\xfb")
out2 = s.feed(b"\x03bbb")
assert out1 == b"aaa"
assert out2 == b"bbb"
def test_split_sub_negotiation(self):
"""Sub-negotiation without closing IAC SE — buffers until next chunk."""
s = _TelnetStripper()
out1 = s.feed(b"x\xff\xfa\x01\x00")
out2 = s.feed(b"\xff\xf0y")
assert out1 == b"x"
assert out2 == b"y"
def test_multiple_commands_in_one_chunk(self):
s = _TelnetStripper()
data = b"\xff\xfb\x01\xff\xfc\x03text\xff\xfd\x01"
assert s.feed(data) == b"text"
def test_empty_input(self):
s = _TelnetStripper()
assert s.feed(b"") == b""
def test_just_iac_byte(self):
"""Single 0xFF byte — should buffer, waiting for next byte."""
s = _TelnetStripper()
out1 = s.feed(b"\xff")
assert out1 == b""
out2 = s.feed(b"\xfb\x01done")
assert out2 == b"done"
def test_unknown_iac_command(self):
"""Unknown command byte after IAC — stripped as 2-byte sequence."""
s = _TelnetStripper()
assert s.feed(b"\xff\xf1text") == b"text"
class TestFrameParsing:
"""Tests for the ESC[H frame boundary detection logic."""
def test_frame_split_on_cursor_home(self):
"""ESC[H splits data into frames."""
data = b"frame1\x1b[Hframe2\x1b[Hframe3"
frames = data.split(b"\x1b[H")
assert frames == [b"frame1", b"frame2", b"frame3"]
def test_esc_j_in_frame(self):
"""ESC[J (clear to end) can be stripped from frame data."""
data = b"\x1b[Jsome text here"
clean = data.replace(b"\x1b[J", b"")
assert clean == b"some text here"
def test_empty_frames_between_homes(self):
"""Consecutive ESC[H produces empty frames (filtered in code)."""
data = b"\x1b[H\x1b[Htext"
frames = data.split(b"\x1b[H")
non_empty = [f for f in frames if f.strip()]
assert non_empty == [b"text"]
"""Tests for the stateful telnet IAC sequence stripper."""
from skywalker_tui.screens.starwars import _TelnetStripper
class TestTelnetStripper:
"""Edge cases for IAC parsing across chunk boundaries."""
def test_plain_text_passthrough(self):
s = _TelnetStripper()
assert s.feed(b"hello world") == b"hello world"
def test_strips_will_command(self):
# IAC WILL ECHO = FF FB 01
s = _TelnetStripper()
assert s.feed(b"\xff\xfb\x01hello") == b"hello"
def test_strips_wont_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfc\x03data") == b"data"
def test_strips_do_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfd\x01data") == b"data"
def test_strips_dont_command(self):
s = _TelnetStripper()
assert s.feed(b"\xff\xfe\x01data") == b"data"
def test_escaped_0xff(self):
"""Doubled 0xFF = literal 0xFF byte in content."""
s = _TelnetStripper()
assert s.feed(b"\xff\xff") == b"\xff"
def test_sub_negotiation(self):
# IAC SB 0x01 ... IAC SE = FF FA 01 xx xx FF F0
s = _TelnetStripper()
data = b"before\xff\xfa\x01\x00\x00\xff\xf0after"
assert s.feed(data) == b"beforeafter"
def test_split_iac_across_chunks(self):
"""IAC command split: FF in chunk 1, FB 01 in chunk 2."""
s = _TelnetStripper()
out1 = s.feed(b"hello\xff")
out2 = s.feed(b"\xfb\x01world")
assert out1 == b"hello"
assert out2 == b"world"
def test_split_will_at_boundary(self):
"""IAC WILL split: FF FB in chunk 1, option byte in chunk 2."""
s = _TelnetStripper()
out1 = s.feed(b"aaa\xff\xfb")
out2 = s.feed(b"\x03bbb")
assert out1 == b"aaa"
assert out2 == b"bbb"
def test_split_sub_negotiation(self):
"""Sub-negotiation without closing IAC SE — buffers until next chunk."""
s = _TelnetStripper()
out1 = s.feed(b"x\xff\xfa\x01\x00")
out2 = s.feed(b"\xff\xf0y")
assert out1 == b"x"
assert out2 == b"y"
def test_multiple_commands_in_one_chunk(self):
s = _TelnetStripper()
data = b"\xff\xfb\x01\xff\xfc\x03text\xff\xfd\x01"
assert s.feed(data) == b"text"
def test_empty_input(self):
s = _TelnetStripper()
assert s.feed(b"") == b""
def test_just_iac_byte(self):
"""Single 0xFF byte — should buffer, waiting for next byte."""
s = _TelnetStripper()
out1 = s.feed(b"\xff")
assert out1 == b""
out2 = s.feed(b"\xfb\x01done")
assert out2 == b"done"
def test_unknown_iac_command(self):
"""Unknown command byte after IAC — stripped as 2-byte sequence."""
s = _TelnetStripper()
assert s.feed(b"\xff\xf1text") == b"text"
class TestFrameParsing:
"""Tests for the ESC[H frame boundary detection logic."""
def test_frame_split_on_cursor_home(self):
"""ESC[H splits data into frames."""
data = b"frame1\x1b[Hframe2\x1b[Hframe3"
frames = data.split(b"\x1b[H")
assert frames == [b"frame1", b"frame2", b"frame3"]
def test_esc_j_in_frame(self):
"""ESC[J (clear to end) can be stripped from frame data."""
data = b"\x1b[Jsome text here"
clean = data.replace(b"\x1b[J", b"")
assert clean == b"some text here"
def test_empty_frames_between_homes(self):
"""Consecutive ESC[H produces empty frames (filtered in code)."""
data = b"\x1b[H\x1b[Htext"
frames = data.split(b"\x1b[H")
non_empty = [f for f in frames if f.strip()]
assert non_empty == [b"text"]