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:
parent
696d2dd387
commit
bbdcb243dc
175 changed files with 56794 additions and 56794 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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[/]"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (950–2150 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.1–500 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 (950–2150 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.1–500 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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 (950–2150 MHz)[/]")
|
||||
return
|
||||
if not (256 <= sr <= 30000):
|
||||
log.write("[bold #e04040]Symbol rate out of range (256–30000 ksps)[/]")
|
||||
return
|
||||
if not (0.1 <= rate <= 100):
|
||||
log.write("[bold #e04040]Rate out of range (0.1–100 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 (950–2150 MHz)[/]")
|
||||
return
|
||||
if not (256 <= sr <= 30000):
|
||||
log.write("[bold #e04040]Symbol rate out of range (256–30000 ksps)[/]")
|
||||
return
|
||||
if not (0.1 <= rate <= 100):
|
||||
log.write("[bold #e04040]Rate out of range (0.1–100 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}[/]")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
"""Custom widgets for SkyWalker-1 TUI."""
|
||||
"""Custom widgets for SkyWalker-1 TUI."""
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[/]"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}]")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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[/]")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != ""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue