mirror of
https://github.com/smittix/intercept.git
synced 2026-07-22 00:08:10 -07:00
Merge pull request #202 from mitchross/misc-fixes
Fix Meteor LRPT, global timezone, VDL2 correlation, and weather sat UX
This commit is contained in:
+25
-6
@@ -126,6 +126,7 @@ RUN cd /tmp \
|
|||||||
&& rm -rf /tmp/slowrx
|
&& rm -rf /tmp/slowrx
|
||||||
|
|
||||||
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||||
|
# Split into compile (heavy, cached) and staging (light, safe to change) layers
|
||||||
RUN cd /tmp \
|
RUN cd /tmp \
|
||||||
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||||
&& cd SatDump \
|
&& cd SatDump \
|
||||||
@@ -147,14 +148,29 @@ RUN cd /tmp \
|
|||||||
fi; \
|
fi; \
|
||||||
done; \
|
done; \
|
||||||
fi \
|
fi \
|
||||||
# Copy SatDump install artifacts to staging
|
|
||||||
&& cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/lib/satdump /staging/usr/local/lib/ 2>/dev/null || true \
|
|
||||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null; mkdir -p /staging/usr/local/share \
|
|
||||||
&& cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null || true \
|
|
||||||
&& rm -rf /tmp/SatDump
|
&& rm -rf /tmp/SatDump
|
||||||
|
|
||||||
|
# Stage SatDump artifacts (separate layer so compile cache survives staging changes)
|
||||||
|
# On arm64 cmake installs to /usr/{bin,lib,share}; on x86 to /usr/local/{bin,lib,share}
|
||||||
|
RUN mkdir -p /staging/usr/local/share /staging/usr/local/lib/satdump/plugins \
|
||||||
|
# Binary
|
||||||
|
&& (cp -a /usr/local/bin/satdump /staging/usr/local/bin/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/bin/satdump /staging/usr/local/bin/) \
|
||||||
|
# Core shared library
|
||||||
|
&& (cp -a /usr/local/lib/libsatdump* /staging/usr/local/lib/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/lib/libsatdump* /staging/usr/local/lib/) \
|
||||||
|
# Plugins
|
||||||
|
&& (cp -a /usr/local/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/lib/satdump/plugins/*.so /staging/usr/local/lib/satdump/plugins/ 2>/dev/null \
|
||||||
|
|| true) \
|
||||||
|
# Pipeline definitions and resources
|
||||||
|
&& (cp -a /usr/local/share/satdump /staging/usr/local/share/ 2>/dev/null \
|
||||||
|
|| cp -a /usr/share/satdump /staging/usr/local/share/) \
|
||||||
|
# Verify
|
||||||
|
&& test -x /staging/usr/local/bin/satdump \
|
||||||
|
&& ls /staging/usr/local/share/satdump/pipelines/*.json >/dev/null 2>&1 \
|
||||||
|
&& echo "SatDump staging OK: $(ls /staging/usr/local/share/satdump/pipelines/*.json | wc -l) pipeline files"
|
||||||
|
|
||||||
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||||
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||||
RUN cd /tmp \
|
RUN cd /tmp \
|
||||||
@@ -219,6 +235,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpng16-16 \
|
libpng16-16 \
|
||||||
libtiff6 \
|
libtiff6 \
|
||||||
libjemalloc2 \
|
libjemalloc2 \
|
||||||
|
libfftw3-double3 \
|
||||||
|
libfftw3-single3 \
|
||||||
libvolk-bin \
|
libvolk-bin \
|
||||||
libnng1 \
|
libnng1 \
|
||||||
libzstd1 \
|
libzstd1 \
|
||||||
@@ -254,6 +272,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
COPY --from=builder /staging/usr/bin/ /usr/bin/
|
||||||
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
COPY --from=builder /staging/usr/local/bin/ /usr/local/bin/
|
||||||
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
COPY --from=builder /staging/usr/local/lib/ /usr/local/lib/
|
||||||
|
COPY --from=builder /staging/usr/local/share/ /usr/local/share/
|
||||||
COPY --from=builder /staging/opt/ /opt/
|
COPY --from=builder /staging/opt/ /opt/
|
||||||
|
|
||||||
# Copy radiosonde Python dependencies installed during builder stage
|
# Copy radiosonde Python dependencies installed during builder stage
|
||||||
|
|||||||
@@ -436,6 +436,11 @@ def get_sdr_device_status() -> dict[str, str]:
|
|||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_login():
|
def require_login():
|
||||||
|
# Skip auth entirely when INTERCEPT_DISABLE_AUTH is set
|
||||||
|
if os.environ.get('INTERCEPT_DISABLE_AUTH', '').lower() in ('1', 'true', 'yes'):
|
||||||
|
session['logged_in'] = True
|
||||||
|
return None
|
||||||
|
|
||||||
# Routes that don't require login (to avoid infinite redirect loop)
|
# Routes that don't require login (to avoid infinite redirect loop)
|
||||||
allowed_routes = ["login", "static", "favicon", "health", "health_check"]
|
allowed_routes = ["login", "static", "favicon", "health", "health_check"]
|
||||||
|
|
||||||
|
|||||||
+8
-11
@@ -6,17 +6,15 @@
|
|||||||
# Basic usage (build locally):
|
# Basic usage (build locally):
|
||||||
# docker compose --profile basic up -d --build
|
# docker compose --profile basic up -d --build
|
||||||
#
|
#
|
||||||
# Basic usage (pre-built image from registry):
|
|
||||||
# INTERCEPT_IMAGE=ghcr.io/user/intercept:latest docker compose --profile basic up -d
|
|
||||||
#
|
|
||||||
# With ADS-B history (Postgres):
|
# With ADS-B history (Postgres):
|
||||||
# docker compose --profile history up -d
|
# docker compose --profile history up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
# When INTERCEPT_IMAGE is set, use that pre-built image; otherwise build locally
|
# Always build and use the local image
|
||||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
image: intercept:latest
|
||||||
build: .
|
build: .
|
||||||
|
pull_policy: never
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
@@ -72,9 +70,10 @@ services:
|
|||||||
# ADS-B history with Postgres persistence
|
# ADS-B history with Postgres persistence
|
||||||
# Enable with: docker compose --profile history up -d
|
# Enable with: docker compose --profile history up -d
|
||||||
intercept-history:
|
intercept-history:
|
||||||
# Same image/build fallback pattern as above
|
# Always build and use the local image
|
||||||
image: ${INTERCEPT_IMAGE:-intercept:latest}
|
image: intercept:latest
|
||||||
build: .
|
build: .
|
||||||
|
pull_policy: never
|
||||||
container_name: intercept-history
|
container_name: intercept-history
|
||||||
profiles:
|
profiles:
|
||||||
- history
|
- history
|
||||||
@@ -112,6 +111,8 @@ services:
|
|||||||
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
- INTERCEPT_ADSB_AUTO_START=${INTERCEPT_ADSB_AUTO_START:-false}
|
||||||
# Shared observer location across modules
|
# Shared observer location across modules
|
||||||
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
- INTERCEPT_SHARED_OBSERVER_LOCATION=${INTERCEPT_SHARED_OBSERVER_LOCATION:-true}
|
||||||
|
# Disable login auth (set to true for local/dev use)
|
||||||
|
- INTERCEPT_DISABLE_AUTH=${INTERCEPT_DISABLE_AUTH:-false}
|
||||||
# Default observer coordinates (set to your location to skip the GPS prompt)
|
# Default observer coordinates (set to your location to skip the GPS prompt)
|
||||||
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
# - INTERCEPT_DEFAULT_LAT=${INTERCEPT_DEFAULT_LAT:-0}
|
||||||
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
# - INTERCEPT_DEFAULT_LON=${INTERCEPT_DEFAULT_LON:-0}
|
||||||
@@ -142,7 +143,3 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# Optional: Add volume for persistent SQLite database
|
|
||||||
# volumes:
|
|
||||||
# intercept-data:
|
|
||||||
|
|||||||
+27
-3
@@ -83,11 +83,35 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) ->
|
|||||||
data['type'] = 'vdl2'
|
data['type'] = 'vdl2'
|
||||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
# Enrich with translated ACARS label at top level (consistent with ACARS route)
|
# Flatten nested VDL2 identifying fields to top level for correlator matching
|
||||||
|
# dumpvdl2 nests flight/reg inside vdl2.avlc.acars and ICAO in avlc.src.addr
|
||||||
try:
|
try:
|
||||||
vdl2_inner = data.get('vdl2', data)
|
vdl2_inner = data.get('vdl2', data)
|
||||||
acars_payload = (vdl2_inner.get('avlc') or {}).get('acars')
|
avlc = vdl2_inner.get('avlc') or {}
|
||||||
if acars_payload and acars_payload.get('label'):
|
acars_payload = avlc.get('acars') or {}
|
||||||
|
|
||||||
|
# Promote AVLC source address — this is the aircraft ICAO hex
|
||||||
|
# Do this FIRST so even non-ACARS VDL2 frames can be correlated
|
||||||
|
src = avlc.get('src') or {}
|
||||||
|
src_addr = src.get('addr', '')
|
||||||
|
src_type = src.get('type', '')
|
||||||
|
if src_addr and src_type == 'Aircraft':
|
||||||
|
data['icao'] = src_addr.upper()
|
||||||
|
data['addr'] = src_addr.upper()
|
||||||
|
|
||||||
|
# Promote ACARS fields to top level so FlightCorrelator can match them
|
||||||
|
if acars_payload.get('flight'):
|
||||||
|
data['flight'] = acars_payload['flight']
|
||||||
|
if acars_payload.get('reg'):
|
||||||
|
data['reg'] = acars_payload['reg']
|
||||||
|
data['tail'] = acars_payload['reg']
|
||||||
|
if acars_payload.get('label'):
|
||||||
|
data['label'] = acars_payload['label']
|
||||||
|
if acars_payload.get('msg_text'):
|
||||||
|
data['text'] = acars_payload['msg_text']
|
||||||
|
|
||||||
|
# Enrich with translated ACARS label (consistent with ACARS route)
|
||||||
|
if acars_payload.get('label'):
|
||||||
translation = translate_message({
|
translation = translate_message({
|
||||||
'label': acars_payload.get('label'),
|
'label': acars_payload.get('label'),
|
||||||
'text': acars_payload.get('msg_text', ''),
|
'text': acars_payload.get('msg_text', ''),
|
||||||
|
|||||||
@@ -107,6 +107,23 @@
|
|||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Timezone Select ===== */
|
||||||
|
.wxsat-tz-select {
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-tz-select:focus {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Auto-Schedule Toggle ===== */
|
/* ===== Auto-Schedule Toggle ===== */
|
||||||
.wxsat-schedule-toggle {
|
.wxsat-schedule-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -317,6 +334,161 @@
|
|||||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Pass Analysis Bar ===== */
|
||||||
|
.wxsat-analysis-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-analysis-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-analysis-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-analysis-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-analysis-value.excellent { color: var(--neon-green); }
|
||||||
|
.wxsat-analysis-value.good { color: var(--accent-cyan); }
|
||||||
|
.wxsat-analysis-value.fair { color: var(--accent-yellow); }
|
||||||
|
|
||||||
|
.wxsat-analysis-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-analysis-best {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Best Pass Badge ===== */
|
||||||
|
.wxsat-pass-best-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(0, 255, 136, 0.15);
|
||||||
|
color: var(--neon-green);
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pass Direction ===== */
|
||||||
|
.wxsat-pass-direction {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-direction .wxsat-dir-arrow {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pass Geometry Detail ===== */
|
||||||
|
.wxsat-pass-geometry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-event {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-event.wxsat-geom-tca {
|
||||||
|
color: var(--neon-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-tca .wxsat-geom-label {
|
||||||
|
color: var(--neon-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-time {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-tca .wxsat-geom-time {
|
||||||
|
color: var(--neon-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-az {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-arrow {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim, #444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-geom-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-left: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px solid var(--border-color, #2a3040);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Countdown Pulse Animation ===== */
|
||||||
|
.wxsat-countdown-box.imminent .wxsat-cd-value {
|
||||||
|
animation: wxsat-count-pulse 1s ease-in-out infinite;
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-countdown-box.active .wxsat-cd-value {
|
||||||
|
animation: wxsat-count-pulse 1s ease-in-out infinite;
|
||||||
|
color: var(--neon-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wxsat-count-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.15); opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Pass Predictions Panel ===== */
|
/* ===== Pass Predictions Panel ===== */
|
||||||
.wxsat-passes-panel {
|
.wxsat-passes-panel {
|
||||||
flex: 0 0 280px;
|
flex: 0 0 280px;
|
||||||
@@ -1066,6 +1238,51 @@
|
|||||||
color: var(--text-dim, #444);
|
color: var(--text-dim, #444);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Console filter buttons */
|
||||||
|
.wxsat-console-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-console-filter {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim, #555);
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-console-filter:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-console-filter.active {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
background: rgba(0, 212, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-console-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Console entry timestamps */
|
||||||
|
.wxsat-console-ts {
|
||||||
|
color: var(--text-dim, #444);
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
#wxsatConsoleToggle {
|
#wxsatConsoleToggle {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
|||||||
+23
-3
@@ -77,8 +77,23 @@ function declineDisclaimer() {
|
|||||||
|
|
||||||
function updateHeaderClock() {
|
function updateHeaderClock() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const utc = now.toISOString().substring(11, 19);
|
const el = document.getElementById('headerUtcTime');
|
||||||
document.getElementById('headerUtcTime').textContent = utc;
|
const label = document.querySelector('.utc-label');
|
||||||
|
if (typeof InterceptTime !== 'undefined') {
|
||||||
|
if (el) el.textContent = InterceptTime.fullTime(now);
|
||||||
|
if (label) label.textContent = InterceptTime.getLabel() || 'LOCAL';
|
||||||
|
} else {
|
||||||
|
if (el) el.textContent = now.toISOString().substring(11, 19);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTimeSettings() {
|
||||||
|
const tzSelect = document.getElementById('globalTimezoneSelect');
|
||||||
|
const fmtSelect = document.getElementById('globalTimeFormatSelect');
|
||||||
|
if (typeof InterceptTime !== 'undefined') {
|
||||||
|
if (tzSelect) tzSelect.value = InterceptTime.getTimezone();
|
||||||
|
if (fmtSelect) fmtSelect.value = InterceptTime.getHour12() ? '12' : '24';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== MODE SWITCHING ==============
|
// ============== MODE SWITCHING ==============
|
||||||
@@ -447,9 +462,14 @@ function initApp() {
|
|||||||
// Load theme
|
// Load theme
|
||||||
loadTheme();
|
loadTheme();
|
||||||
|
|
||||||
// Start clock
|
// Start clock and init time settings
|
||||||
|
initTimeSettings();
|
||||||
updateHeaderClock();
|
updateHeaderClock();
|
||||||
|
window._navClockStarted = true; // Prevent nav.html from starting a duplicate interval
|
||||||
setInterval(updateHeaderClock, 1000);
|
setInterval(updateHeaderClock, 1000);
|
||||||
|
if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
|
||||||
|
InterceptTime.onChange(updateHeaderClock);
|
||||||
|
}
|
||||||
|
|
||||||
// Load bias-T setting
|
// Load bias-T setting
|
||||||
loadBiasTSetting();
|
loadBiasTSetting();
|
||||||
|
|||||||
@@ -55,6 +55,114 @@ function isValidChannel(ch) {
|
|||||||
|
|
||||||
// ============== TIME FORMATTING ==============
|
// ============== TIME FORMATTING ==============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global time preferences — timezone and 12h/24h format.
|
||||||
|
* Stored in localStorage, used by all modes.
|
||||||
|
*/
|
||||||
|
const InterceptTime = (function() {
|
||||||
|
const TZ_MAP = {
|
||||||
|
'UTC': 'UTC',
|
||||||
|
'local': undefined,
|
||||||
|
'US/Eastern': 'America/New_York',
|
||||||
|
'US/Central': 'America/Chicago',
|
||||||
|
'US/Mountain': 'America/Denver',
|
||||||
|
'US/Pacific': 'America/Los_Angeles',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TZ_LABELS = {
|
||||||
|
'UTC': 'UTC',
|
||||||
|
'local': '',
|
||||||
|
'US/Eastern': 'ET',
|
||||||
|
'US/Central': 'CT',
|
||||||
|
'US/Mountain': 'MT',
|
||||||
|
'US/Pacific': 'PT',
|
||||||
|
};
|
||||||
|
|
||||||
|
let _timezone = localStorage.getItem('interceptTimezone') || 'US/Eastern';
|
||||||
|
let _hour12 = (localStorage.getItem('interceptHour12') || 'true') === 'true';
|
||||||
|
const _listeners = [];
|
||||||
|
|
||||||
|
function getTimezone() { return _timezone; }
|
||||||
|
function getHour12() { return _hour12; }
|
||||||
|
function getIANA() { return TZ_MAP[_timezone]; }
|
||||||
|
function getLabel() { return TZ_LABELS[_timezone] || ''; }
|
||||||
|
|
||||||
|
function setTimezone(tz) {
|
||||||
|
if (!TZ_MAP.hasOwnProperty(tz)) return;
|
||||||
|
_timezone = tz;
|
||||||
|
localStorage.setItem('interceptTimezone', tz);
|
||||||
|
// Migrate weather-sat specific key
|
||||||
|
localStorage.setItem('wxsatTimezone', tz);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHour12(val) {
|
||||||
|
_hour12 = !!val;
|
||||||
|
localStorage.setItem('interceptHour12', _hour12 ? 'true' : 'false');
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(fn) { _listeners.push(fn); }
|
||||||
|
function _notify() { _listeners.forEach(fn => { try { fn(); } catch(e) { console.error(e); } }); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date or ISO string for the global timezone.
|
||||||
|
* @param {Date|string} input - Date object or ISO string
|
||||||
|
* @param {object} [extraOpts] - Additional Intl.DateTimeFormat options
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function format(input, extraOpts) {
|
||||||
|
if (!input) return '--';
|
||||||
|
try {
|
||||||
|
const date = typeof input === 'string' ? new Date(input) : input;
|
||||||
|
if (isNaN(date.getTime())) return typeof input === 'string' ? input : '--';
|
||||||
|
const opts = { hour12: _hour12, ...extraOpts };
|
||||||
|
const iana = getIANA();
|
||||||
|
if (iana) opts.timeZone = iana;
|
||||||
|
return date.toLocaleString(undefined, opts);
|
||||||
|
} catch { return typeof input === 'string' ? input : '--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HH:MM (or h:MM AM/PM) */
|
||||||
|
function shortTime(input) {
|
||||||
|
return format(input, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HH:MM:SS */
|
||||||
|
function fullTime(input) {
|
||||||
|
return format(input, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mon 25, 14:30 (or 2:30 PM) */
|
||||||
|
function dateTime(input) {
|
||||||
|
return format(input, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mon 25, 2026 */
|
||||||
|
function dateOnly(input) {
|
||||||
|
const iana = getIANA();
|
||||||
|
const opts = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||||
|
if (iana) opts.timeZone = iana;
|
||||||
|
try {
|
||||||
|
const date = typeof input === 'string' ? new Date(input) : input;
|
||||||
|
return date.toLocaleDateString(undefined, opts);
|
||||||
|
} catch { return '--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short label like " ET" or " UTC" for appending to times */
|
||||||
|
function tzSuffix() {
|
||||||
|
const l = getLabel();
|
||||||
|
return l ? ' ' + l : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getTimezone, getHour12, getIANA, getLabel,
|
||||||
|
setTimezone, setHour12, onChange,
|
||||||
|
format, shortTime, fullTime, dateTime, dateOnly, tzSuffix,
|
||||||
|
TZ_MAP, TZ_LABELS,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get relative time string from timestamp
|
* Get relative time string from timestamp
|
||||||
* @param {string} timestamp - Time string in HH:MM:SS format
|
* @param {string} timestamp - Time string in HH:MM:SS format
|
||||||
|
|||||||
@@ -36,11 +36,60 @@ const WeatherSat = (function() {
|
|||||||
let imageRefreshInterval = null;
|
let imageRefreshInterval = null;
|
||||||
let lastDecodeJobSignature = null;
|
let lastDecodeJobSignature = null;
|
||||||
let lastDecodeSatellite = null;
|
let lastDecodeSatellite = null;
|
||||||
|
let consoleFilter = 'all';
|
||||||
|
|
||||||
|
// Timezone — delegates to global InterceptTime utility
|
||||||
|
function formatShortTime(isoString) {
|
||||||
|
return typeof InterceptTime !== 'undefined' ? InterceptTime.shortTime(isoString) : (isoString || '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(isoString) {
|
||||||
|
return typeof InterceptTime !== 'undefined' ? InterceptTime.dateTime(isoString) : (isoString || '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTZLabel() {
|
||||||
|
return typeof InterceptTime !== 'undefined' ? InterceptTime.tzSuffix() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimezone(tz) {
|
||||||
|
if (typeof InterceptTime !== 'undefined') InterceptTime.setTimezone(tz);
|
||||||
|
const sel = document.getElementById('wxsatTimezone');
|
||||||
|
if (sel && sel.value !== tz) sel.value = tz;
|
||||||
|
applyPassFilter();
|
||||||
|
renderGallery();
|
||||||
|
updateTimelineLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an azimuth angle (0-360) to a cardinal direction label.
|
||||||
|
*/
|
||||||
|
function azToDir(az) {
|
||||||
|
if (typeof az !== 'number' || isNaN(az)) return '?';
|
||||||
|
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||||
|
return dirs[Math.round(az / 22.5) % 16];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best upcoming pass (highest max elevation).
|
||||||
|
*/
|
||||||
|
function findBestPass(passList) {
|
||||||
|
const now = new Date();
|
||||||
|
const upcoming = passList.filter(p => {
|
||||||
|
const end = parsePassDate(p.endTimeISO);
|
||||||
|
return end && end > now;
|
||||||
|
});
|
||||||
|
if (upcoming.length === 0) return null;
|
||||||
|
return upcoming.reduce((best, p) => (p.maxEl > best.maxEl) ? p : best, upcoming[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Weather Satellite mode
|
* Initialize the Weather Satellite mode
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
|
// Sync timezone selector with global setting
|
||||||
|
const tzSel = document.getElementById('wxsatTimezone');
|
||||||
|
if (tzSel && typeof InterceptTime !== 'undefined') tzSel.value = InterceptTime.getTimezone();
|
||||||
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
checkStatus();
|
checkStatus();
|
||||||
loadImages();
|
loadImages();
|
||||||
@@ -54,6 +103,17 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
|
// Listen for global timezone/format changes
|
||||||
|
if (typeof InterceptTime !== 'undefined') {
|
||||||
|
InterceptTime.onChange(() => {
|
||||||
|
const sel = document.getElementById('wxsatTimezone');
|
||||||
|
if (sel) sel.value = InterceptTime.getTimezone();
|
||||||
|
applyPassFilter();
|
||||||
|
renderGallery();
|
||||||
|
updateTimelineLabels();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
checkStatus();
|
checkStatus();
|
||||||
loadImages();
|
loadImages();
|
||||||
loadLocationInputs();
|
loadLocationInputs();
|
||||||
@@ -643,12 +703,15 @@ const WeatherSat = (function() {
|
|||||||
renderPasses([]);
|
renderPasses([]);
|
||||||
renderTimeline([]);
|
renderTimeline([]);
|
||||||
updateCountdownFromPasses();
|
updateCountdownFromPasses();
|
||||||
|
updatePassAnalysis([]);
|
||||||
updateGroundTrack(null);
|
updateGroundTrack(null);
|
||||||
|
const passCountEl = document.getElementById('wxsatStripPassCount');
|
||||||
|
if (passCountEl) passCountEl.textContent = '0';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
|
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=48&min_elevation=5&trajectory=true&ground_track=true`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -675,7 +738,12 @@ const WeatherSat = (function() {
|
|||||||
selectedPassIndex = -1;
|
selectedPassIndex = -1;
|
||||||
renderPasses(passes);
|
renderPasses(passes);
|
||||||
renderTimeline(passes);
|
renderTimeline(passes);
|
||||||
|
updateTimelineLabels();
|
||||||
updateCountdownFromPasses();
|
updateCountdownFromPasses();
|
||||||
|
updatePassAnalysis(passes);
|
||||||
|
// Update strip pass count
|
||||||
|
const passCountEl = document.getElementById('wxsatStripPassCount');
|
||||||
|
if (passCountEl) passCountEl.textContent = passes.length;
|
||||||
if (passes.length > 0) {
|
if (passes.length > 0) {
|
||||||
selectPass(0);
|
selectPass(0);
|
||||||
} else {
|
} else {
|
||||||
@@ -707,6 +775,42 @@ const WeatherSat = (function() {
|
|||||||
// Update polar panel subtitle
|
// Update polar panel subtitle
|
||||||
const polarSat = document.getElementById('wxsatPolarSat');
|
const polarSat = document.getElementById('wxsatPolarSat');
|
||||||
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
|
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
|
||||||
|
|
||||||
|
// Update pass geometry detail panel
|
||||||
|
updatePassGeometry(pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the AOS/TCA/LOS pass geometry detail panel.
|
||||||
|
*/
|
||||||
|
function updatePassGeometry(pass) {
|
||||||
|
const panel = document.getElementById('wxsatPassGeometry');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
|
||||||
|
const aosTime = document.getElementById('wxsatGeomAosTime');
|
||||||
|
const aosAz = document.getElementById('wxsatGeomAosAz');
|
||||||
|
const tcaEl = document.getElementById('wxsatGeomTcaEl');
|
||||||
|
const tcaAz = document.getElementById('wxsatGeomTcaAz');
|
||||||
|
const losTime = document.getElementById('wxsatGeomLosTime');
|
||||||
|
const losAz = document.getElementById('wxsatGeomLosAz');
|
||||||
|
const meta = document.getElementById('wxsatGeomMeta');
|
||||||
|
|
||||||
|
const tzLabel = getTZLabel();
|
||||||
|
if (aosTime) aosTime.textContent = formatShortTime(pass.startTimeISO) + tzLabel;
|
||||||
|
if (aosAz) aosAz.textContent = `${Math.round(pass.riseAz || 0)}\u00b0 ${azToDir(pass.riseAz)}`;
|
||||||
|
if (tcaEl) tcaEl.textContent = `${pass.maxEl}\u00b0 el`;
|
||||||
|
if (tcaAz) tcaAz.textContent = `${Math.round(pass.maxElAz || pass.tcaAz || 0)}\u00b0 ${azToDir(pass.maxElAz || pass.tcaAz)}`;
|
||||||
|
if (losTime) losTime.textContent = formatShortTime(pass.endTimeISO) + tzLabel;
|
||||||
|
if (losAz) losAz.textContent = `${Math.round(pass.setAz || 0)}\u00b0 ${azToDir(pass.setAz)}`;
|
||||||
|
|
||||||
|
const durMin = Math.round((pass.duration || 0) / 60);
|
||||||
|
if (meta) meta.textContent = `${durMin} min / ${pass.quality}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -721,23 +825,42 @@ const WeatherSat = (function() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (passList.length === 0) {
|
if (passList.length === 0) {
|
||||||
const hasLocation = localStorage.getItem('observerLat') !== null;
|
const hasLocation = localStorage.getItem('observerLat') !== null ||
|
||||||
|
(window.ObserverLocation && ObserverLocation.isSharedEnabled() && ObserverLocation.getShared()?.lat);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="wxsat-gallery-empty">
|
<div class="wxsat-gallery-empty">
|
||||||
<p>${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}</p>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
|
||||||
|
${hasLocation
|
||||||
|
? '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'
|
||||||
|
: '<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>'}
|
||||||
|
</svg>
|
||||||
|
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">
|
||||||
|
${hasLocation ? 'No passes in next 24 hours' : 'Set your location'}
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 11px; margin-top: 4px;">
|
||||||
|
${hasLocation
|
||||||
|
? 'All Meteor passes may be below the minimum elevation. Try again later.'
|
||||||
|
: 'Enter lat/lon in the strip bar above or click GPS to load pass predictions'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
// Hide geometry panel when no passes
|
||||||
|
const geom = document.getElementById('wxsatPassGeometry');
|
||||||
|
if (geom) geom.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bestPass = findBestPass(passList);
|
||||||
|
|
||||||
container.innerHTML = passList.map((pass, idx) => {
|
container.innerHTML = passList.map((pass, idx) => {
|
||||||
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
||||||
const timeStr = pass.startTime || '--';
|
const timeStr = formatDateTime(pass.startTimeISO) + getTZLabel();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const passStart = parsePassDate(pass.startTimeISO);
|
const passStart = parsePassDate(pass.startTimeISO);
|
||||||
const diffMs = passStart ? passStart - now : NaN;
|
const diffMs = passStart ? passStart - now : NaN;
|
||||||
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
|
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
|
||||||
const isSelected = idx === selectedPassIndex;
|
const isSelected = idx === selectedPassIndex;
|
||||||
|
const isBest = bestPass && pass.startTimeISO === bestPass.startTimeISO && pass.satellite === bestPass.satellite;
|
||||||
|
|
||||||
let countdown = '--';
|
let countdown = '--';
|
||||||
if (!Number.isFinite(diffMs)) {
|
if (!Number.isFinite(diffMs)) {
|
||||||
@@ -752,21 +875,29 @@ const WeatherSat = (function() {
|
|||||||
countdown = `in ${hrs}h${mins}m`;
|
countdown = `in ${hrs}h${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const riseDir = azToDir(pass.riseAz);
|
||||||
|
const setDir = azToDir(pass.setAz);
|
||||||
|
const bestBadge = isBest ? '<span class="wxsat-pass-best-badge">BEST</span>' : '';
|
||||||
|
const durMin = Math.round((pass.duration || 0) / 60);
|
||||||
|
const aosStr = formatShortTime(pass.startTimeISO);
|
||||||
|
const losStr = formatShortTime(pass.endTimeISO);
|
||||||
|
const tzLabel = getTZLabel();
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
|
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
|
||||||
<div class="wxsat-pass-sat">
|
<div class="wxsat-pass-sat">
|
||||||
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
|
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}${bestBadge}</span>
|
||||||
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
|
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="wxsat-pass-details">
|
<div class="wxsat-pass-details">
|
||||||
<span class="wxsat-pass-detail-label">Time</span>
|
<span class="wxsat-pass-detail-label">AOS</span>
|
||||||
<span class="wxsat-pass-detail-value">${escapeHtml(timeStr)}</span>
|
<span class="wxsat-pass-detail-value">${escapeHtml(aosStr)}${escapeHtml(tzLabel)} · ${Math.round(pass.riseAz || 0)}° ${riseDir}</span>
|
||||||
<span class="wxsat-pass-detail-label">Max El</span>
|
<span class="wxsat-pass-detail-label">LOS</span>
|
||||||
<span class="wxsat-pass-detail-value">${pass.maxEl}°</span>
|
<span class="wxsat-pass-detail-value">${escapeHtml(losStr)}${escapeHtml(tzLabel)} · ${Math.round(pass.setAz || 0)}° ${setDir}</span>
|
||||||
<span class="wxsat-pass-detail-label">Duration</span>
|
<span class="wxsat-pass-detail-label">Peak</span>
|
||||||
<span class="wxsat-pass-detail-value">${pass.duration} min</span>
|
<span class="wxsat-pass-detail-value">${pass.maxEl}° el · ${durMin} min</span>
|
||||||
<span class="wxsat-pass-detail-label">Freq</span>
|
<span class="wxsat-pass-detail-label">Track</span>
|
||||||
<span class="wxsat-pass-detail-value">${pass.frequency} MHz</span>
|
<span class="wxsat-pass-detail-value">${riseDir} <span class="wxsat-dir-arrow">→</span> ${setDir}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
|
||||||
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
|
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
|
||||||
@@ -1334,12 +1465,18 @@ const WeatherSat = (function() {
|
|||||||
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
|
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
|
||||||
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
|
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
|
||||||
if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
|
if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
|
||||||
|
const passTimeStr = formatShortTime(nextPass.startTimeISO) + getTZLabel();
|
||||||
if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
|
if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
|
||||||
if (detailEl) {
|
if (detailEl) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
|
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
|
||||||
} else {
|
} else {
|
||||||
detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`;
|
const bestPass = findBestPass(filtered);
|
||||||
|
const durMin = Math.round((nextPass.duration || 0) / 60);
|
||||||
|
const bestNote = bestPass && bestPass.startTimeISO !== nextPass.startTimeISO
|
||||||
|
? ` | Best: ${bestPass.name} ${formatShortTime(bestPass.startTimeISO)}${getTZLabel()} (${bestPass.maxEl}\u00b0)`
|
||||||
|
: '';
|
||||||
|
detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${durMin} min${bestNote}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,7 +1528,7 @@ const WeatherSat = (function() {
|
|||||||
marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
|
marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
|
||||||
marker.style.left = startPct + '%';
|
marker.style.left = startPct + '%';
|
||||||
marker.style.width = widthPct + '%';
|
marker.style.width = widthPct + '%';
|
||||||
marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`;
|
marker.title = `${pass.name} ${formatShortTime(pass.startTimeISO)}${getTZLabel()} (${pass.maxEl}\u00b0)`;
|
||||||
marker.onclick = () => selectPass(idx);
|
marker.onclick = () => selectPass(idx);
|
||||||
track.appendChild(marker);
|
track.appendChild(marker);
|
||||||
});
|
});
|
||||||
@@ -1414,6 +1551,73 @@ const WeatherSat = (function() {
|
|||||||
cursor.style.left = pct + '%';
|
cursor.style.left = pct + '%';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update timeline hour labels to match the selected timezone.
|
||||||
|
*/
|
||||||
|
function updateTimelineLabels() {
|
||||||
|
const labels = document.querySelector('.wxsat-timeline-labels');
|
||||||
|
if (!labels) return;
|
||||||
|
const hours = [0, 6, 12, 18, 24];
|
||||||
|
const spans = labels.querySelectorAll('span');
|
||||||
|
if (spans.length !== hours.length) return;
|
||||||
|
|
||||||
|
const tz = typeof InterceptTime !== 'undefined' ? InterceptTime.getTimezone() : 'UTC';
|
||||||
|
const ianaName = typeof InterceptTime !== 'undefined' ? InterceptTime.getIANA() : undefined;
|
||||||
|
|
||||||
|
hours.forEach((h, i) => {
|
||||||
|
if (h === 24) {
|
||||||
|
spans[i].textContent = '24:00';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tz === 'UTC' || tz === 'local') {
|
||||||
|
spans[i].textContent = `${String(h).padStart(2, '0')}:00`;
|
||||||
|
} else {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(h, 0, 0, 0);
|
||||||
|
const opts = { hour: '2-digit', minute: '2-digit', hour12: false };
|
||||||
|
if (ianaName) opts.timeZone = ianaName;
|
||||||
|
spans[i].textContent = d.toLocaleTimeString(undefined, opts).slice(0, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the pass analysis bar with stats about current passes.
|
||||||
|
*/
|
||||||
|
function updatePassAnalysis(passList) {
|
||||||
|
const totalEl = document.getElementById('wxsatAnalysisTotal');
|
||||||
|
const excellentEl = document.getElementById('wxsatAnalysisExcellent');
|
||||||
|
const goodEl = document.getElementById('wxsatAnalysisGood');
|
||||||
|
const fairEl = document.getElementById('wxsatAnalysisFair');
|
||||||
|
const bestEl = document.getElementById('wxsatAnalysisBest');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const upcoming = passList.filter(p => {
|
||||||
|
const end = parsePassDate(p.endTimeISO);
|
||||||
|
return end && end > now;
|
||||||
|
});
|
||||||
|
|
||||||
|
const excellent = upcoming.filter(p => p.quality === 'excellent').length;
|
||||||
|
const good = upcoming.filter(p => p.quality === 'good').length;
|
||||||
|
const fair = upcoming.filter(p => p.quality === 'fair').length;
|
||||||
|
|
||||||
|
if (totalEl) totalEl.textContent = upcoming.length;
|
||||||
|
if (excellentEl) excellentEl.textContent = excellent;
|
||||||
|
if (goodEl) goodEl.textContent = good;
|
||||||
|
if (fairEl) fairEl.textContent = fair;
|
||||||
|
|
||||||
|
const best = findBestPass(passList);
|
||||||
|
if (bestEl) {
|
||||||
|
if (best) {
|
||||||
|
const t = formatShortTime(best.startTimeISO) + getTZLabel();
|
||||||
|
const bestDurMin = Math.round((best.duration || 0) / 60);
|
||||||
|
bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${bestDurMin} min)`;
|
||||||
|
} else {
|
||||||
|
bestEl.textContent = 'No upcoming passes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Auto-Scheduler
|
// Auto-Scheduler
|
||||||
// ========================
|
// ========================
|
||||||
@@ -1627,12 +1831,15 @@ const WeatherSat = (function() {
|
|||||||
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group by date
|
// Group by date (timezone-aware via global InterceptTime)
|
||||||
const groups = {};
|
const groups = {};
|
||||||
sorted.forEach(img => {
|
sorted.forEach(img => {
|
||||||
const dateKey = img.timestamp
|
let dateKey = 'Unknown Date';
|
||||||
? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
if (img.timestamp) {
|
||||||
: 'Unknown Date';
|
dateKey = typeof InterceptTime !== 'undefined'
|
||||||
|
? InterceptTime.dateOnly(img.timestamp)
|
||||||
|
: new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
if (!groups[dateKey]) groups[dateKey] = [];
|
if (!groups[dateKey]) groups[dateKey] = [];
|
||||||
groups[dateKey].push(img);
|
groups[dateKey].push(img);
|
||||||
});
|
});
|
||||||
@@ -1788,11 +1995,7 @@ const WeatherSat = (function() {
|
|||||||
*/
|
*/
|
||||||
function formatTimestamp(isoString) {
|
function formatTimestamp(isoString) {
|
||||||
if (!isoString) return '--';
|
if (!isoString) return '--';
|
||||||
try {
|
return formatDateTime(isoString) + getTZLabel();
|
||||||
return new Date(isoString).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return isoString;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureImageRefresh() {
|
function ensureImageRefresh() {
|
||||||
@@ -1969,11 +2172,23 @@ const WeatherSat = (function() {
|
|||||||
const log = document.getElementById('wxsatConsoleLog');
|
const log = document.getElementById('wxsatConsoleLog');
|
||||||
if (!log) return;
|
if (!log) return;
|
||||||
|
|
||||||
const entry = document.createElement('div');
|
const type = logType || 'info';
|
||||||
entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`;
|
const now = new Date();
|
||||||
entry.textContent = message;
|
const ts = typeof InterceptTime !== 'undefined'
|
||||||
log.appendChild(entry);
|
? InterceptTime.fullTime(now)
|
||||||
|
: now.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `wxsat-console-entry wxsat-log-${type}`;
|
||||||
|
entry.dataset.logType = type;
|
||||||
|
entry.innerHTML = `<span class="wxsat-console-ts">${ts}</span>${escapeHtml(message)}`;
|
||||||
|
|
||||||
|
// Apply current filter visibility
|
||||||
|
if (consoleFilter !== 'all' && type !== consoleFilter) {
|
||||||
|
entry.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
log.appendChild(entry);
|
||||||
consoleEntries.push(entry);
|
consoleEntries.push(entry);
|
||||||
|
|
||||||
// Cap at 200 entries
|
// Cap at 200 entries
|
||||||
@@ -1986,6 +2201,40 @@ const WeatherSat = (function() {
|
|||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter console entries by log type.
|
||||||
|
*/
|
||||||
|
function filterConsole(filter) {
|
||||||
|
consoleFilter = filter;
|
||||||
|
// Update filter button states
|
||||||
|
document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||||||
|
});
|
||||||
|
// Show/hide entries
|
||||||
|
consoleEntries.forEach(entry => {
|
||||||
|
if (filter === 'all') {
|
||||||
|
entry.style.display = '';
|
||||||
|
} else {
|
||||||
|
entry.style.display = entry.dataset.logType === filter ? '' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Scroll to bottom
|
||||||
|
const log = document.getElementById('wxsatConsoleLog');
|
||||||
|
if (log) log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export console contents to clipboard.
|
||||||
|
*/
|
||||||
|
function exportConsole() {
|
||||||
|
const text = consoleEntries.map(e => e.textContent).join('\n');
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
showNotification('Weather Sat', 'Console log copied to clipboard');
|
||||||
|
}).catch(() => {
|
||||||
|
showNotification('Weather Sat', 'Failed to copy console log');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the phase indicator steps
|
* Update the phase indicator steps
|
||||||
*/
|
*/
|
||||||
@@ -2048,8 +2297,14 @@ const WeatherSat = (function() {
|
|||||||
const log = document.getElementById('wxsatConsoleLog');
|
const log = document.getElementById('wxsatConsoleLog');
|
||||||
if (log) log.innerHTML = '';
|
if (log) log.innerHTML = '';
|
||||||
consoleEntries = [];
|
consoleEntries = [];
|
||||||
|
consoleFilter = 'all';
|
||||||
currentPhase = 'idle';
|
currentPhase = 'idle';
|
||||||
|
|
||||||
|
// Reset filter buttons
|
||||||
|
document.querySelectorAll('.wxsat-console-filter').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.filter === 'all');
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
|
document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => {
|
||||||
step.classList.remove('active', 'completed', 'error');
|
step.classList.remove('active', 'completed', 'error');
|
||||||
});
|
});
|
||||||
@@ -2092,6 +2347,90 @@ const WeatherSat = (function() {
|
|||||||
stopStream();
|
stopStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load demo/sample data for UI testing without a live satellite pass.
|
||||||
|
* Populates passes, console, and analysis bar with realistic fake data.
|
||||||
|
*/
|
||||||
|
function loadDemoData() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Generate sample passes over next 24h
|
||||||
|
const demoSats = ['METEOR-M2-3', 'METEOR-M2-4'];
|
||||||
|
const demoPasses = [];
|
||||||
|
|
||||||
|
const offsets = [25, 95, 200, 340, 510, 720, 880, 1020];
|
||||||
|
const elevations = [72, 45, 28, 63, 18, 55, 82, 35];
|
||||||
|
const durations = [840, 720, 480, 780, 360, 660, 900, 600]; // seconds
|
||||||
|
const riseAzs = [350, 15, 200, 310, 170, 40, 280, 90];
|
||||||
|
const setAzs = [170, 195, 20, 130, 350, 220, 100, 270];
|
||||||
|
|
||||||
|
offsets.forEach((offset, i) => {
|
||||||
|
const start = new Date(now.getTime() + offset * 60000);
|
||||||
|
const end = new Date(start.getTime() + durations[i] * 1000);
|
||||||
|
const sat = demoSats[i % 2];
|
||||||
|
const el = elevations[i];
|
||||||
|
const quality = el >= 60 ? 'excellent' : el >= 30 ? 'good' : 'fair';
|
||||||
|
|
||||||
|
demoPasses.push({
|
||||||
|
id: `${sat}_demo_${i}`,
|
||||||
|
satellite: sat,
|
||||||
|
name: sat === 'METEOR-M2-3' ? 'Meteor-M2-3' : 'Meteor-M2-4',
|
||||||
|
frequency: 137.9,
|
||||||
|
mode: 'LRPT',
|
||||||
|
startTime: start.toISOString().replace('T', ' ').slice(0, 16) + ' UTC',
|
||||||
|
startTimeISO: start.toISOString(),
|
||||||
|
endTimeISO: end.toISOString(),
|
||||||
|
maxEl: el,
|
||||||
|
maxElAz: (riseAzs[i] + setAzs[i]) / 2,
|
||||||
|
riseAz: riseAzs[i],
|
||||||
|
setAz: setAzs[i],
|
||||||
|
duration: durations[i],
|
||||||
|
quality: quality,
|
||||||
|
trajectory: [],
|
||||||
|
groundTrack: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
allPasses = demoPasses;
|
||||||
|
applyPassFilter();
|
||||||
|
|
||||||
|
// Simulate console output
|
||||||
|
clearConsole();
|
||||||
|
showConsole(true);
|
||||||
|
const demoLogs = [
|
||||||
|
['SatDump v1.2.2 initialized', 'info'],
|
||||||
|
['Pipeline: meteor_m2-x_lrpt', 'info'],
|
||||||
|
['Frequency: 137.900 MHz | Sample rate: 2.4 MHz', 'info'],
|
||||||
|
['RTL-SDR device 0 (SN: 00000101) opened', 'info'],
|
||||||
|
['Tuning to 137900000 Hz...', 'info'],
|
||||||
|
['Gain set to 40.0 dB', 'debug'],
|
||||||
|
['Waiting for signal...', 'info'],
|
||||||
|
['LRPT signal detected! SNR: 8.2 dB', 'signal'],
|
||||||
|
['Viterbi lock acquired', 'signal'],
|
||||||
|
['Frame sync OK - decoding frames', 'signal'],
|
||||||
|
['Decoding LRPT... 15%', 'progress'],
|
||||||
|
['Decoding LRPT... 30%', 'progress'],
|
||||||
|
['Decoding LRPT... 45%', 'progress'],
|
||||||
|
['Channel 1 (visible) - 1540 lines', 'info'],
|
||||||
|
['Channel 2 (infrared) - 1540 lines', 'info'],
|
||||||
|
['Decoding LRPT... 60%', 'progress'],
|
||||||
|
['Decoding LRPT... 75%', 'progress'],
|
||||||
|
['Decoding LRPT... 90%', 'progress'],
|
||||||
|
['Image saved: meteor_m2-3_rgb_composite.png (2.4 MB)', 'save'],
|
||||||
|
['Image saved: meteor_m2-3_channel_1.png (1.1 MB)', 'save'],
|
||||||
|
['Image saved: meteor_m2-3_thermal.png (1.3 MB)', 'save'],
|
||||||
|
['Decoding complete - 3 images produced', 'info'],
|
||||||
|
['Signal lost - satellite below horizon', 'warning'],
|
||||||
|
['Pass duration: 13m 42s', 'info'],
|
||||||
|
];
|
||||||
|
|
||||||
|
demoLogs.forEach((entry, i) => {
|
||||||
|
setTimeout(() => addConsoleEntry(entry[0], entry[1]), i * 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
showNotification('Weather Sat', 'Demo data loaded - showing sample passes and console output');
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
@@ -2113,6 +2452,11 @@ const WeatherSat = (function() {
|
|||||||
toggleScheduler,
|
toggleScheduler,
|
||||||
invalidateMap,
|
invalidateMap,
|
||||||
toggleConsole,
|
toggleConsole,
|
||||||
|
setTimezone,
|
||||||
|
filterConsole,
|
||||||
|
exportConsole,
|
||||||
|
clearConsole,
|
||||||
|
loadDemoData,
|
||||||
_getModalFilename: () => currentModalFilename,
|
_getModalFilename: () => currentModalFilename,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -3489,15 +3490,17 @@ sudo make install</code>
|
|||||||
return '<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:' + color + ';">' + lbl + '</span>';
|
return '<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:' + color + ';">' + lbl + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Similar to renderAcarsMainCard in partials/modes/acars.html — consider unifying
|
|
||||||
function renderAcarsCard(msg) {
|
function renderAcarsCard(msg) {
|
||||||
const type = msg.message_type || 'other';
|
const type = msg.message_type || 'other';
|
||||||
const badge = getAcarsTypeBadge(type);
|
const badge = getAcarsTypeBadge(type);
|
||||||
const desc = escapeHtml(msg.label_description || ('Label ' + (msg.label || '?')));
|
const desc = escapeHtml(msg.label_description || ('Label ' + (msg.label || '?')));
|
||||||
const text = msg.text || msg.msg || '';
|
const text = msg.text || msg.msg || '';
|
||||||
const truncText = escapeHtml(text.length > 120 ? text.substring(0, 120) + '...' : text);
|
const truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
|
||||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
|
const time = msg.timestamp && typeof InterceptTime !== 'undefined'
|
||||||
|
? InterceptTime.shortTime(msg.timestamp) + InterceptTime.tzSuffix()
|
||||||
|
: (msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '');
|
||||||
const flight = escapeHtml(msg.flight || '');
|
const flight = escapeHtml(msg.flight || '');
|
||||||
|
const tail = escapeHtml(msg.tail || msg.reg || '');
|
||||||
|
|
||||||
let parsedHtml = '';
|
let parsedHtml = '';
|
||||||
if (msg.parsed) {
|
if (msg.parsed) {
|
||||||
@@ -3505,23 +3508,35 @@ sudo make install</code>
|
|||||||
if (type === 'position' && p.lat !== undefined) {
|
if (type === 'position' && p.lat !== undefined) {
|
||||||
parsedHtml = '<div style="color:var(--accent-green);margin-top:2px;">' +
|
parsedHtml = '<div style="color:var(--accent-green);margin-top:2px;">' +
|
||||||
p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) +
|
p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) +
|
||||||
(p.flight_level ? ' • ' + escapeHtml(String(p.flight_level)) : '') +
|
(p.flight_level ? ' • FL' + escapeHtml(String(p.flight_level)) : '') +
|
||||||
(p.destination ? ' → ' + escapeHtml(String(p.destination)) : '') + '</div>';
|
(p.destination ? ' → ' + escapeHtml(String(p.destination)) : '') + '</div>';
|
||||||
} else if (type === 'engine_data') {
|
} else if (type === 'engine_data') {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
Object.keys(p).forEach(k => {
|
Object.keys(p).forEach(k => {
|
||||||
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(p[k].value)));
|
const val = typeof p[k] === 'object' ? p[k].value : p[k];
|
||||||
|
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
|
||||||
});
|
});
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
parsedHtml = '<div style="color:var(--accent-orange,#ff9500);margin-top:2px;">' + parts.slice(0, 4).join(' | ') + '</div>';
|
parsedHtml = '<div style="color:var(--accent-orange,#ff9500);margin-top:2px;">' + parts.slice(0, 4).join(' | ') + '</div>';
|
||||||
}
|
}
|
||||||
} else if (type === 'oooi' && p.origin) {
|
} else if (type === 'oooi' && p.origin) {
|
||||||
parsedHtml = '<div style="color:var(--accent-cyan);margin-top:2px;">' +
|
parsedHtml = '<div style="color:var(--accent-cyan);margin-top:2px;">' +
|
||||||
escapeHtml(String(p.origin)) + ' → ' + escapeHtml(String(p.destination)) +
|
escapeHtml(String(p.origin)) + ' → ' + escapeHtml(String(p.destination)) +
|
||||||
(p.out ? ' | OUT ' + escapeHtml(String(p.out)) : '') +
|
(p.out ? ' | OUT ' + escapeHtml(String(p.out)) : '') +
|
||||||
(p.off ? ' OFF ' + escapeHtml(String(p.off)) : '') +
|
(p.off ? ' OFF ' + escapeHtml(String(p.off)) : '') +
|
||||||
(p.on ? ' ON ' + escapeHtml(String(p.on)) : '') +
|
(p.on ? ' ON ' + escapeHtml(String(p.on)) : '') +
|
||||||
(p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : '') + '</div>';
|
(p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : '') + '</div>';
|
||||||
|
} else if (type === 'weather' && (p.wind_speed || p.temperature)) {
|
||||||
|
const wx = [];
|
||||||
|
if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
|
||||||
|
if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
|
||||||
|
if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
|
||||||
|
if (wx.length) parsedHtml = '<div style="color:#00d4ff;margin-top:2px;">' + wx.join(' | ') + '</div>';
|
||||||
|
} else if (type === 'cpdlc') {
|
||||||
|
const cpdlcText = p.message || p.text || '';
|
||||||
|
if (cpdlcText) parsedHtml = '<div style="color:#b388ff;margin-top:2px;font-weight:600;">' + escapeHtml(String(cpdlcText)) + '</div>';
|
||||||
|
} else if (type === 'squawk' && p.squawk) {
|
||||||
|
parsedHtml = '<div style="color:#ff6b6b;margin-top:2px;font-weight:600;">Squawk: ' + escapeHtml(String(p.squawk)) + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3529,7 +3544,7 @@ sudo make install</code>
|
|||||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">' +
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">' +
|
||||||
'<span>' + badge + ' <span style="color:var(--text-primary);">' + desc + '</span></span>' +
|
'<span>' + badge + ' <span style="color:var(--text-primary);">' + desc + '</span></span>' +
|
||||||
'<span style="color:var(--text-muted);font-size:9px;">' + time + '</span></div>' +
|
'<span style="color:var(--text-muted);font-size:9px;">' + time + '</span></div>' +
|
||||||
(flight ? '<div style="color:var(--accent-cyan);font-size:9px;">' + flight + '</div>' : '') +
|
(flight || tail ? '<div style="color:var(--accent-cyan);font-size:9px;">' + flight + (tail ? ' (' + tail + ')' : '') + '</div>' : '') +
|
||||||
parsedHtml +
|
parsedHtml +
|
||||||
(truncText && type !== 'link_test' && type !== 'handshake' ?
|
(truncText && type !== 'link_test' && type !== 'handshake' ?
|
||||||
'<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:2px;word-break:break-all;">' + truncText + '</div>' : '') +
|
'<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:2px;word-break:break-all;">' + truncText + '</div>' : '') +
|
||||||
@@ -4362,7 +4377,9 @@ sudo make install</code>
|
|||||||
const labelDesc = data.label_description || '';
|
const labelDesc = data.label_description || '';
|
||||||
const msgType = data.message_type || 'other';
|
const msgType = data.message_type || 'other';
|
||||||
const text = data.text || data.msg || '';
|
const text = data.text || data.msg || '';
|
||||||
const time = new Date().toLocaleTimeString();
|
const time = typeof InterceptTime !== 'undefined'
|
||||||
|
? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
|
||||||
|
: new Date().toLocaleTimeString();
|
||||||
|
|
||||||
// Escape user-controlled strings for safe innerHTML insertion
|
// Escape user-controlled strings for safe innerHTML insertion
|
||||||
const eFlight = escapeHtml(flight);
|
const eFlight = escapeHtml(flight);
|
||||||
@@ -4878,7 +4895,9 @@ sudo make install</code>
|
|||||||
const acars = avlc.acars || {};
|
const acars = avlc.acars || {};
|
||||||
const flight = acars.flight || '';
|
const flight = acars.flight || '';
|
||||||
const msgText = acars.msg_text || '';
|
const msgText = acars.msg_text || '';
|
||||||
const time = new Date().toLocaleTimeString();
|
const time = typeof InterceptTime !== 'undefined'
|
||||||
|
? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
|
||||||
|
: new Date().toLocaleTimeString();
|
||||||
const freq = inner.freq ? (inner.freq / 1000000).toFixed(3) : '';
|
const freq = inner.freq ? (inner.freq / 1000000).toFixed(3) : '';
|
||||||
|
|
||||||
// Store for CSV export
|
// Store for CSV export
|
||||||
@@ -5676,6 +5695,16 @@ sudo make install</code>
|
|||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
{% include 'partials/settings-modal.html' %}
|
{% include 'partials/settings-modal.html' %}
|
||||||
|
<script>
|
||||||
|
// Initialize timezone/format dropdowns from saved preferences
|
||||||
|
(function() {
|
||||||
|
if (typeof InterceptTime === 'undefined') return;
|
||||||
|
var tzSel = document.getElementById('globalTimezoneSelect');
|
||||||
|
var fmtSel = document.getElementById('globalTimeFormatSelect');
|
||||||
|
if (tzSel) tzSel.value = InterceptTime.getTimezone();
|
||||||
|
if (fmtSel) fmtSel.value = InterceptTime.getHour12() ? '12' : '24';
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|||||||
+95
-7
@@ -2545,6 +2545,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wxsat-strip-divider"></div>
|
<div class="wxsat-strip-divider"></div>
|
||||||
|
<div class="wxsat-strip-group">
|
||||||
|
<div class="wxsat-strip-stat">
|
||||||
|
<span class="wxsat-strip-value" id="wxsatStripPassCount">0</span>
|
||||||
|
<span class="wxsat-strip-label">PASSES</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-strip-divider"></div>
|
||||||
|
<div class="wxsat-strip-group">
|
||||||
|
<span class="wxsat-strip-label" style="margin-right: 4px;">TZ</span>
|
||||||
|
<select id="wxsatTimezone" class="wxsat-tz-select" onchange="WeatherSat.setTimezone(this.value)">
|
||||||
|
<option value="UTC">UTC</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
<option value="US/Eastern">Eastern</option>
|
||||||
|
<option value="US/Central">Central</option>
|
||||||
|
<option value="US/Mountain">Mountain</option>
|
||||||
|
<option value="US/Pacific">Pacific</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-strip-divider"></div>
|
||||||
<div class="wxsat-strip-group">
|
<div class="wxsat-strip-group">
|
||||||
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
|
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
|
||||||
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
|
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
|
||||||
@@ -2576,6 +2595,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pass Analysis Bar -->
|
||||||
|
<div class="wxsat-analysis-bar" id="wxsatAnalysisBar">
|
||||||
|
<div class="wxsat-analysis-stats">
|
||||||
|
<div class="wxsat-analysis-stat">
|
||||||
|
<span class="wxsat-analysis-value" id="wxsatAnalysisTotal">0</span>
|
||||||
|
<span class="wxsat-analysis-label">24h passes</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-analysis-stat">
|
||||||
|
<span class="wxsat-analysis-value excellent" id="wxsatAnalysisExcellent">0</span>
|
||||||
|
<span class="wxsat-analysis-label">excellent</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-analysis-stat">
|
||||||
|
<span class="wxsat-analysis-value good" id="wxsatAnalysisGood">0</span>
|
||||||
|
<span class="wxsat-analysis-label">good</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-analysis-stat">
|
||||||
|
<span class="wxsat-analysis-value fair" id="wxsatAnalysisFair">0</span>
|
||||||
|
<span class="wxsat-analysis-label">fair</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-analysis-best" id="wxsatAnalysisBest">--</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Capture progress -->
|
<!-- Capture progress -->
|
||||||
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
||||||
<div class="wxsat-capture-info">
|
<div class="wxsat-capture-info">
|
||||||
@@ -2604,8 +2646,18 @@
|
|||||||
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
|
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
|
<div class="wxsat-console-filters" id="wxsatConsoleFilters">
|
||||||
onclick="WeatherSat.toggleConsole()" title="Toggle console">▼</button>
|
<button class="wxsat-console-filter active" data-filter="all" onclick="WeatherSat.filterConsole('all')">ALL</button>
|
||||||
|
<button class="wxsat-console-filter" data-filter="signal" onclick="WeatherSat.filterConsole('signal')">SIGNAL</button>
|
||||||
|
<button class="wxsat-console-filter" data-filter="progress" onclick="WeatherSat.filterConsole('progress')">PROG</button>
|
||||||
|
<button class="wxsat-console-filter" data-filter="error" onclick="WeatherSat.filterConsole('error')">ERR</button>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-console-actions">
|
||||||
|
<button class="wxsat-strip-btn" onclick="WeatherSat.exportConsole()" title="Copy log to clipboard">COPY</button>
|
||||||
|
<button class="wxsat-strip-btn" onclick="WeatherSat.clearConsole()" title="Clear console">CLR</button>
|
||||||
|
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
|
||||||
|
onclick="WeatherSat.toggleConsole()" title="Toggle console">▼</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
||||||
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
||||||
@@ -2621,10 +2673,36 @@
|
|||||||
<div class="wxsat-passes-header">
|
<div class="wxsat-passes-header">
|
||||||
<span class="wxsat-passes-title">Upcoming Passes</span>
|
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||||||
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
||||||
|
<button class="wxsat-strip-btn" onclick="WeatherSat.loadPasses()" title="Refresh pass predictions" style="margin-left: auto; font-size: 9px; padding: 2px 6px;">REFRESH</button>
|
||||||
|
</div>
|
||||||
|
<!-- Pass geometry detail (shown when pass selected) -->
|
||||||
|
<div class="wxsat-pass-geometry" id="wxsatPassGeometry" style="display: none;">
|
||||||
|
<div class="wxsat-geom-event">
|
||||||
|
<span class="wxsat-geom-label">AOS</span>
|
||||||
|
<span class="wxsat-geom-time" id="wxsatGeomAosTime">--</span>
|
||||||
|
<span class="wxsat-geom-az" id="wxsatGeomAosAz">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-geom-arrow">→</div>
|
||||||
|
<div class="wxsat-geom-event wxsat-geom-tca">
|
||||||
|
<span class="wxsat-geom-label">TCA</span>
|
||||||
|
<span class="wxsat-geom-time" id="wxsatGeomTcaEl">--</span>
|
||||||
|
<span class="wxsat-geom-az" id="wxsatGeomTcaAz">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-geom-arrow">→</div>
|
||||||
|
<div class="wxsat-geom-event">
|
||||||
|
<span class="wxsat-geom-label">LOS</span>
|
||||||
|
<span class="wxsat-geom-time" id="wxsatGeomLosTime">--</span>
|
||||||
|
<span class="wxsat-geom-az" id="wxsatGeomLosAz">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-geom-meta" id="wxsatGeomMeta">--</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wxsat-passes-list" id="wxsatPassesList">
|
<div class="wxsat-passes-list" id="wxsatPassesList">
|
||||||
<div class="wxsat-gallery-empty">
|
<div class="wxsat-gallery-empty">
|
||||||
<p>Set location to see pass predictions</p>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
|
||||||
|
<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
|
||||||
|
</svg>
|
||||||
|
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">Set your location</p>
|
||||||
|
<p style="font-size: 11px; margin-top: 4px;">Enter lat/lon in the strip bar above or click the GPS button to load pass predictions</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3799,11 +3877,17 @@
|
|||||||
keywords: []
|
keywords: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// UTC Clock Update
|
// Clock Update (uses global InterceptTime for timezone/format)
|
||||||
function updateHeaderClock() {
|
function updateHeaderClock() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const utc = now.toISOString().substring(11, 19);
|
const el = document.getElementById('headerUtcTime');
|
||||||
document.getElementById('headerUtcTime').textContent = utc;
|
const label = document.querySelector('.utc-label');
|
||||||
|
if (typeof InterceptTime !== 'undefined') {
|
||||||
|
if (el) el.textContent = InterceptTime.fullTime(now);
|
||||||
|
if (label) label.textContent = InterceptTime.getLabel() || 'LOCAL';
|
||||||
|
} else {
|
||||||
|
if (el) el.textContent = now.toISOString().substring(11, 19);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveModeIndicator(label) {
|
function setActiveModeIndicator(label) {
|
||||||
@@ -3838,8 +3922,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update clock every second
|
// Update clock every second
|
||||||
|
window._navClockStarted = true;
|
||||||
setInterval(updateHeaderClock, 1000);
|
setInterval(updateHeaderClock, 1000);
|
||||||
updateHeaderClock(); // Initial call
|
updateHeaderClock();
|
||||||
|
if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
|
||||||
|
InterceptTime.onChange(updateHeaderClock);
|
||||||
|
}
|
||||||
applyKeyboardAccessibility();
|
applyKeyboardAccessibility();
|
||||||
|
|
||||||
// Pager message filter functions
|
// Pager message filter functions
|
||||||
|
|||||||
@@ -188,33 +188,49 @@
|
|||||||
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
|
return `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:${color};">${lbl}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Similar to renderAcarsCard in templates/adsb_dashboard.html — consider unifying
|
|
||||||
function renderAcarsMainCard(data) {
|
function renderAcarsMainCard(data) {
|
||||||
const flight = escapeHtml(data.flight || 'UNKNOWN');
|
const flight = escapeHtml(data.flight || 'UNKNOWN');
|
||||||
|
const tail = escapeHtml(data.tail || data.reg || '');
|
||||||
const type = data.message_type || 'other';
|
const type = data.message_type || 'other';
|
||||||
const badge = acarsMainTypeBadge(type);
|
const badge = acarsMainTypeBadge(type);
|
||||||
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
|
const desc = escapeHtml(data.label_description || (data.label ? 'Label: ' + data.label : ''));
|
||||||
const text = data.text || data.msg || '';
|
const text = data.text || data.msg || '';
|
||||||
const truncText = escapeHtml(text.length > 150 ? text.substring(0, 150) + '...' : text);
|
const truncText = escapeHtml(text.length > 200 ? text.substring(0, 200) + '...' : text);
|
||||||
const time = new Date().toLocaleTimeString();
|
const time = typeof InterceptTime !== 'undefined'
|
||||||
|
? InterceptTime.shortTime(new Date()) + InterceptTime.tzSuffix()
|
||||||
|
: new Date().toLocaleTimeString();
|
||||||
|
|
||||||
let parsedHtml = '';
|
let parsedHtml = '';
|
||||||
if (data.parsed) {
|
if (data.parsed) {
|
||||||
const p = data.parsed;
|
const p = data.parsed;
|
||||||
if (type === 'position' && p.lat !== undefined) {
|
if (type === 'position' && p.lat !== undefined) {
|
||||||
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • ' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' → ' + escapeHtml(String(p.destination)) : ''}</div>`;
|
parsedHtml = `<div style="color:var(--accent-green);margin-top:2px;font-size:10px;">${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}${p.flight_level ? ' • FL' + escapeHtml(String(p.flight_level)) : ''}${p.destination ? ' → ' + escapeHtml(String(p.destination)) : ''}</div>`;
|
||||||
} else if (type === 'engine_data') {
|
} else if (type === 'engine_data') {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
Object.keys(p).forEach(k => parts.push(escapeHtml(k) + ': ' + escapeHtml(String(p[k].value))));
|
Object.keys(p).forEach(k => {
|
||||||
|
const val = typeof p[k] === 'object' ? p[k].value : p[k];
|
||||||
|
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(val)));
|
||||||
|
});
|
||||||
if (parts.length) parsedHtml = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
|
if (parts.length) parsedHtml = `<div style="color:#ff9500;margin-top:2px;font-size:10px;">${parts.slice(0, 4).join(' | ')}</div>`;
|
||||||
} else if (type === 'oooi' && p.origin) {
|
} else if (type === 'oooi' && p.origin) {
|
||||||
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} → ${escapeHtml(String(p.destination))}${p.out ? ' | OUT ' + escapeHtml(String(p.out)) : ''}${p.off ? ' OFF ' + escapeHtml(String(p.off)) : ''}${p.on ? ' ON ' + escapeHtml(String(p.on)) : ''}${p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : ''}</div>`;
|
parsedHtml = `<div style="color:var(--accent-cyan);margin-top:2px;font-size:10px;">${escapeHtml(String(p.origin))} → ${escapeHtml(String(p.destination))}${p.out ? ' | OUT ' + escapeHtml(String(p.out)) : ''}${p.off ? ' OFF ' + escapeHtml(String(p.off)) : ''}${p.on ? ' ON ' + escapeHtml(String(p.on)) : ''}${p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : ''}</div>`;
|
||||||
|
} else if (type === 'weather' && (p.wind_speed || p.temperature)) {
|
||||||
|
const wx = [];
|
||||||
|
if (p.wind_speed) wx.push('Wind ' + escapeHtml(String(p.wind_speed)) + (p.wind_dir ? '/' + escapeHtml(String(p.wind_dir)) : ''));
|
||||||
|
if (p.temperature) wx.push(escapeHtml(String(p.temperature)) + '°C');
|
||||||
|
if (p.turbulence) wx.push('Turb: ' + escapeHtml(String(p.turbulence)));
|
||||||
|
if (wx.length) parsedHtml = `<div style="color:#00d4ff;margin-top:2px;font-size:10px;">${wx.join(' | ')}</div>`;
|
||||||
|
} else if (type === 'cpdlc') {
|
||||||
|
const cpdlcText = p.message || p.text || '';
|
||||||
|
if (cpdlcText) parsedHtml = `<div style="color:#b388ff;margin-top:2px;font-size:10px;font-weight:600;">${escapeHtml(String(cpdlcText))}</div>`;
|
||||||
|
} else if (type === 'squawk' && p.squawk) {
|
||||||
|
parsedHtml = `<div style="color:#ff6b6b;margin-top:2px;font-size:10px;font-weight:600;">Squawk: ${escapeHtml(String(p.squawk))}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
|
return `<div class="acars-feed-card" style="padding:6px 8px;border-bottom:1px solid var(--border-color);animation:fadeInMsg 0.3s ease;">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">
|
||||||
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}</span>
|
<span style="color:var(--accent-cyan);font-weight:bold;">${flight}${tail ? ' <span style="color:var(--text-muted);font-weight:normal;font-size:9px;">(' + tail + ')</span>' : ''}</span>
|
||||||
<span style="color:var(--text-muted);font-size:9px;">${time}</span>
|
<span style="color:var(--text-muted);font-size:9px;">${time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
|
<div style="margin-top:2px;">${badge} <span style="color:var(--text-primary);">${desc}</span></div>
|
||||||
|
|||||||
@@ -11,13 +11,83 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Getting Started Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Getting Started</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.6;">
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">What are Meteor satellites?</strong>
|
||||||
|
<p style="margin-top: 6px;">
|
||||||
|
Russia's <strong style="color: var(--text-primary);">Meteor-M2-3</strong> and <strong style="color: var(--text-primary);">Meteor-M2-4</strong>
|
||||||
|
are polar-orbiting weather satellites that continuously transmit real-time color imagery (clouds, land, sea) at <strong style="color: var(--text-primary);">137.900 MHz</strong>
|
||||||
|
using the LRPT digital format. Unlike old analog NOAA APT, LRPT produces sharp, full-color images.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top: 6px;">
|
||||||
|
They orbit ~830 km high, circling the Earth every ~100 minutes in a near-polar sun-synchronous orbit.
|
||||||
|
From any location, you'll typically get <strong style="color: var(--text-primary);">4–8 usable passes per day</strong>,
|
||||||
|
each lasting 8–15 minutes as the satellite crosses your sky.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Step-by-step</strong>
|
||||||
|
<ol style="margin: 6px 0 0 16px; padding: 0;">
|
||||||
|
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Set your location</strong> — Enter your lat/lon in the strip bar above (or click GPS). This is required for pass predictions.</li>
|
||||||
|
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Check upcoming passes</strong> — The pass list shows when each satellite will be overhead. Higher max elevation = better signal. Passes above 30° are "good", above 60° are "excellent".</li>
|
||||||
|
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Prepare your antenna</strong> — You need a 137 MHz antenna outdoors with clear sky (see Antenna Guide below). A $5 V-dipole works for high passes.</li>
|
||||||
|
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Click Capture</strong> on a pass card when it's about to start, or enable <strong style="color: var(--text-primary);">AUTO</strong> to let the scheduler capture automatically.</li>
|
||||||
|
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Wait for images</strong> — SatDump will tune, lock the signal, and decode. Decoded images appear in the gallery after the pass completes.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">When to look</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Best passes:</strong> When the satellite is high overhead (>30° elevation). The countdown timer shows the next one.</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Day vs night:</strong> Daytime passes produce visible-light imagery. Night passes still work but only produce infrared/thermal images.</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Both satellites share 137.9 MHz</strong> so they won't transmit at the same time. You'll see separate pass predictions for each.</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Pass direction:</strong> Meteor satellites travel roughly north→south or south→north. The pass cards show the exact rise/set direction.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">What you need</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">SDR receiver</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RTL-SDR V3/V4 ($25-35)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Antenna</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz V-dipole ($5 DIY) or QFH ($20-30)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">LNA (optional)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz filtered, at antenna ($15-25)</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Location</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Outdoors, clear sky view</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">No hardware?</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Use <em>Load Demo Data</em> below to explore the UI</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Satellite</h3>
|
<h3>Satellite</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Select Satellite</label>
|
<label>Select Satellite</label>
|
||||||
<select id="weatherSatSelect" class="mode-select">
|
<select id="weatherSatSelect" class="mode-select">
|
||||||
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
|
<option value="" selected>All Meteor Satellites</option>
|
||||||
|
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
|
||||||
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
|
<option value="METEOR-M2-4">Meteor-M2-4 (137.900 MHz LRPT)</option>
|
||||||
|
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud (fallback)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -33,10 +103,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Antenna Guide - detailed -->
|
<!-- Antenna Guide - detailed (collapsed by default) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Antenna Guide</h3>
|
<h3 onclick="this.parentElement.querySelector('.wxsat-antenna-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
Antenna Guide
|
||||||
|
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="wxsat-antenna-body wxsat-test-decode-body collapsed" style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
|
||||||
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
|
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
137 MHz band — your stock SDR antenna will NOT work.
|
137 MHz band — your stock SDR antenna will NOT work.
|
||||||
@@ -174,24 +247,33 @@
|
|||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||||
Test Decode (File)
|
Offline Decode (IQ File)
|
||||||
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">▼</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
|
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
|
||||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||||
Decode a pre-recorded Meteor IQ file without SDR hardware.
|
Decode a pre-recorded Meteor IQ baseband file without SDR hardware.
|
||||||
Shared ground-station recordings are also accepted by the backend.
|
You need an actual <code>.raw</code>, <code>.sigmf-data</code>, or <code>.wav</code> recording of a Meteor pass.
|
||||||
</p>
|
</p>
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<strong style="color: var(--text-secondary);">Where to get a test file:</strong>
|
||||||
|
<ul style="margin: 4px 0 0 14px; padding: 0;">
|
||||||
|
<li>Record one yourself with <code>rtl_sdr -f 137900000 -s 2400000 meteor.raw</code> during a pass</li>
|
||||||
|
<li>Download samples from <a href="https://www.sigidwiki.com/wiki/Meteor-M_LRPT" target="_blank" rel="noopener" style="color: var(--accent-cyan);">SigID Wiki</a> or <a href="https://www.sondehub.org/" target="_blank" rel="noopener" style="color: var(--accent-cyan);">community forums</a></li>
|
||||||
|
<li>Place the file in <code>data/weather_sat/</code> on the server</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Satellite</label>
|
<label>Satellite</label>
|
||||||
<select id="wxsatTestSatSelect" class="mode-select">
|
<select id="wxsatTestSatSelect" class="mode-select">
|
||||||
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
|
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
|
||||||
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
|
<option value="METEOR-M2-4">Meteor-M2-4 (LRPT)</option>
|
||||||
|
<option value="METEOR-M2-4-80K">Meteor-M2-4 80k baud</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>File Path (server-side)</label>
|
<label>File Path (server-side, relative to app root)</label>
|
||||||
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
<input type="text" id="wxsatTestFilePath" placeholder="data/weather_sat/my_recording.raw" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Sample Rate</label>
|
<label>Sample Rate</label>
|
||||||
@@ -202,7 +284,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
||||||
Test Decode
|
Decode File
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,6 +306,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Debug / Test</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
|
||||||
|
Load sample pass data and console output to test the UI without an SDR or live satellite pass.
|
||||||
|
</p>
|
||||||
|
<button class="mode-btn" onclick="WeatherSat.loadDemoData()" style="width: 100%;">
|
||||||
|
Load Demo Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
|||||||
@@ -540,12 +540,21 @@
|
|||||||
window._navClockStarted = true;
|
window._navClockStarted = true;
|
||||||
function updateNavUtcClock() {
|
function updateNavUtcClock() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const utc = now.toISOString().slice(11, 19);
|
|
||||||
const el = document.getElementById('headerUtcTime');
|
const el = document.getElementById('headerUtcTime');
|
||||||
if (el) el.textContent = utc;
|
const label = document.querySelector('.utc-label');
|
||||||
|
if (typeof InterceptTime !== 'undefined') {
|
||||||
|
if (el) el.textContent = InterceptTime.fullTime(now);
|
||||||
|
if (label) label.textContent = InterceptTime.getLabel() || 'LOCAL';
|
||||||
|
} else {
|
||||||
|
if (el) el.textContent = now.toISOString().slice(11, 19);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setInterval(updateNavUtcClock, 1000);
|
setInterval(updateNavUtcClock, 1000);
|
||||||
updateNavUtcClock();
|
updateNavUtcClock();
|
||||||
|
// React immediately when timezone/format changes in Settings
|
||||||
|
if (typeof InterceptTime !== 'undefined' && InterceptTime.onChange) {
|
||||||
|
InterceptTime.onChange(updateNavUtcClock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -227,6 +227,36 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Time & Timezone</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Timezone</span>
|
||||||
|
<span class="settings-label-desc">Applied across all modes</span>
|
||||||
|
</div>
|
||||||
|
<select id="globalTimezoneSelect" class="settings-select" onchange="InterceptTime.setTimezone(this.value)">
|
||||||
|
<option value="UTC">UTC</option>
|
||||||
|
<option value="local">Local (browser)</option>
|
||||||
|
<option value="US/Eastern">Eastern (ET)</option>
|
||||||
|
<option value="US/Central">Central (CT)</option>
|
||||||
|
<option value="US/Mountain">Mountain (MT)</option>
|
||||||
|
<option value="US/Pacific">Pacific (PT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Time Format</span>
|
||||||
|
<span class="settings-label-desc">12-hour (2:30 PM) or 24-hour (14:30)</span>
|
||||||
|
</div>
|
||||||
|
<select id="globalTimeFormatSelect" class="settings-select" onchange="InterceptTime.setHour12(this.value === '12')">
|
||||||
|
<option value="12">12-hour (AM/PM)</option>
|
||||||
|
<option value="24">24-hour</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Updates Section -->
|
<!-- Updates Section -->
|
||||||
|
|||||||
+102
-93
@@ -1,14 +1,14 @@
|
|||||||
"""Weather satellite decoder focused on Meteor LRPT workflows.
|
"""Weather satellite decoder focused on Meteor LRPT workflows.
|
||||||
|
|
||||||
Provides automated capture and decoding of weather imagery using SatDump.
|
Provides automated capture and decoding of weather imagery using SatDump.
|
||||||
|
|
||||||
Active satellites:
|
Active satellites:
|
||||||
- Meteor-M2-3: 137.900 MHz (LRPT)
|
- Meteor-M2-3: 137.900 MHz (LRPT)
|
||||||
- Meteor-M2-4: 137.900 MHz (LRPT)
|
- Meteor-M2-4: 137.900 MHz (LRPT)
|
||||||
|
|
||||||
Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
|
Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
|
||||||
and historical metadata, but they are no longer active operational targets.
|
and historical metadata, but they are no longer active operational targets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,17 +29,17 @@ from typing import Callable
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.process import register_process, safe_terminate
|
from utils.process import register_process, safe_terminate
|
||||||
|
|
||||||
logger = get_logger('intercept.weather_sat')
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
ALLOWED_OFFLINE_INPUT_DIRS = (
|
ALLOWED_OFFLINE_INPUT_DIRS = (
|
||||||
PROJECT_ROOT / 'data',
|
PROJECT_ROOT / 'data',
|
||||||
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
|
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Weather satellite definitions.
|
# Weather satellite definitions.
|
||||||
# NOAA APT entries are retained as inactive compatibility metadata.
|
# NOAA APT entries are retained as inactive compatibility metadata.
|
||||||
WEATHER_SATELLITES = {
|
WEATHER_SATELLITES = {
|
||||||
'NOAA-15': {
|
'NOAA-15': {
|
||||||
'name': 'NOAA 15',
|
'name': 'NOAA 15',
|
||||||
@@ -86,6 +86,15 @@ WEATHER_SATELLITES = {
|
|||||||
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
|
'description': 'Meteor-M2-4 LRPT (digital color imagery)',
|
||||||
'active': True,
|
'active': True,
|
||||||
},
|
},
|
||||||
|
'METEOR-M2-4-80K': {
|
||||||
|
'name': 'Meteor-M2-4 (80k)',
|
||||||
|
'frequency': 137.900,
|
||||||
|
'mode': 'LRPT',
|
||||||
|
'pipeline': 'meteor_m2-x_lrpt_80k',
|
||||||
|
'tle_key': 'METEOR-M2-4',
|
||||||
|
'description': 'Meteor-M2-4 LRPT 80k baud (fallback symbol rate)',
|
||||||
|
'active': True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default sample rate for weather satellite reception
|
# Default sample rate for weather satellite reception
|
||||||
@@ -153,12 +162,12 @@ class CaptureProgress:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class WeatherSatDecoder:
|
class WeatherSatDecoder:
|
||||||
"""Weather satellite decoder using SatDump CLI.
|
"""Weather satellite decoder using SatDump CLI.
|
||||||
|
|
||||||
Manages live SDR capture and offline decode for the active Meteor LRPT
|
Manages live SDR capture and offline decode for the active Meteor LRPT
|
||||||
workflow, while preserving compatibility with older weather-sat metadata.
|
workflow, while preserving compatibility with older weather-sat metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, output_dir: str | Path | None = None):
|
def __init__(self, output_dir: str | Path | None = None):
|
||||||
self._process: subprocess.Popen | None = None
|
self._process: subprocess.Popen | None = None
|
||||||
@@ -175,14 +184,14 @@ class WeatherSatDecoder:
|
|||||||
self._pty_master_fd: int | None = None
|
self._pty_master_fd: int | None = None
|
||||||
self._current_satellite: str = ''
|
self._current_satellite: str = ''
|
||||||
self._current_frequency: float = 0.0
|
self._current_frequency: float = 0.0
|
||||||
self._current_mode: str = ''
|
self._current_mode: str = ''
|
||||||
self._capture_start_time: float = 0
|
self._capture_start_time: float = 0
|
||||||
self._device_index: int = -1
|
self._device_index: int = -1
|
||||||
self._capture_output_dir: Path | None = None
|
self._capture_output_dir: Path | None = None
|
||||||
self._on_complete_callback: Callable[[], None] | None = None
|
self._on_complete_callback: Callable[[], None] | None = None
|
||||||
self._capture_phase: str = 'idle'
|
self._capture_phase: str = 'idle'
|
||||||
self._last_error_message: str = ''
|
self._last_error_message: str = ''
|
||||||
self._last_process_returncode: int | None = None
|
self._last_process_returncode: int | None = None
|
||||||
|
|
||||||
# Ensure output directory exists
|
# Ensure output directory exists
|
||||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -251,7 +260,7 @@ class WeatherSatDecoder:
|
|||||||
No SDR hardware is required — SatDump runs in offline mode.
|
No SDR hardware is required — SatDump runs in offline mode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
satellite: Satellite key (for example ``'METEOR-M2-3'``)
|
satellite: Satellite key (for example ``'METEOR-M2-3'``)
|
||||||
input_file: Path to IQ baseband or WAV audio file
|
input_file: Path to IQ baseband or WAV audio file
|
||||||
sample_rate: Sample rate of the recording in Hz
|
sample_rate: Sample rate of the recording in Hz
|
||||||
|
|
||||||
@@ -283,16 +292,16 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
input_path = Path(input_file)
|
input_path = Path(input_file)
|
||||||
|
|
||||||
# Security: restrict offline decode inputs to application-owned
|
# Security: restrict offline decode inputs to application-owned
|
||||||
# capture directories so external paths cannot be injected.
|
# capture directories so external paths cannot be injected.
|
||||||
try:
|
try:
|
||||||
resolved = input_path.resolve()
|
resolved = input_path.resolve()
|
||||||
if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
|
if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
|
||||||
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
|
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
|
||||||
msg = 'Input file must be under INTERCEPT data or ground-station recordings'
|
msg = 'Input file must be under INTERCEPT data or ground-station recordings'
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='error',
|
status='error',
|
||||||
message=msg,
|
message=msg,
|
||||||
))
|
))
|
||||||
return False, msg
|
return False, msg
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -314,13 +323,13 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
self._current_satellite = satellite
|
self._current_satellite = satellite
|
||||||
self._current_frequency = sat_info['frequency']
|
self._current_frequency = sat_info['frequency']
|
||||||
self._current_mode = sat_info['mode']
|
self._current_mode = sat_info['mode']
|
||||||
self._device_index = -1 # Offline decode does not claim an SDR device
|
self._device_index = -1 # Offline decode does not claim an SDR device
|
||||||
self._capture_start_time = time.time()
|
self._capture_start_time = time.time()
|
||||||
self._capture_phase = 'decoding'
|
self._capture_phase = 'decoding'
|
||||||
self._last_error_message = ''
|
self._last_error_message = ''
|
||||||
self._last_process_returncode = None
|
self._last_process_returncode = None
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -368,7 +377,7 @@ class WeatherSatDecoder:
|
|||||||
"""Start weather satellite capture and decode.
|
"""Start weather satellite capture and decode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
satellite: Satellite key (for example ``'METEOR-M2-3'``)
|
satellite: Satellite key (for example ``'METEOR-M2-3'``)
|
||||||
device_index: RTL-SDR device index
|
device_index: RTL-SDR device index
|
||||||
gain: SDR gain in dB
|
gain: SDR gain in dB
|
||||||
sample_rate: Sample rate in Hz
|
sample_rate: Sample rate in Hz
|
||||||
@@ -410,13 +419,13 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
self._current_satellite = satellite
|
self._current_satellite = satellite
|
||||||
self._current_frequency = sat_info['frequency']
|
self._current_frequency = sat_info['frequency']
|
||||||
self._current_mode = sat_info['mode']
|
self._current_mode = sat_info['mode']
|
||||||
self._device_index = device_index
|
self._device_index = device_index
|
||||||
self._capture_start_time = time.time()
|
self._capture_start_time = time.time()
|
||||||
self._capture_phase = 'tuning'
|
self._capture_phase = 'tuning'
|
||||||
self._last_error_message = ''
|
self._last_error_message = ''
|
||||||
self._last_process_returncode = None
|
self._last_process_returncode = None
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -890,17 +899,17 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
if was_running:
|
if was_running:
|
||||||
# Collect exit status (returncode is only set after poll/wait)
|
# Collect exit status (returncode is only set after poll/wait)
|
||||||
if process and process.returncode is None:
|
if process and process.returncode is None:
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=5)
|
process.wait(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
process.kill()
|
||||||
process.wait()
|
process.wait()
|
||||||
retcode = process.returncode if process else None
|
retcode = process.returncode if process else None
|
||||||
self._last_process_returncode = retcode
|
self._last_process_returncode = retcode
|
||||||
if retcode and retcode != 0:
|
if retcode and retcode != 0:
|
||||||
self._capture_phase = 'error'
|
self._capture_phase = 'error'
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='error',
|
status='error',
|
||||||
satellite=self._current_satellite,
|
satellite=self._current_satellite,
|
||||||
frequency=self._current_frequency,
|
frequency=self._current_frequency,
|
||||||
@@ -1143,15 +1152,15 @@ class WeatherSatDecoder:
|
|||||||
self._images.clear()
|
self._images.clear()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def _emit_progress(self, progress: CaptureProgress) -> None:
|
def _emit_progress(self, progress: CaptureProgress) -> None:
|
||||||
"""Emit progress update to callback."""
|
"""Emit progress update to callback."""
|
||||||
if progress.status == 'error' and progress.message:
|
if progress.status == 'error' and progress.message:
|
||||||
self._last_error_message = str(progress.message)
|
self._last_error_message = str(progress.message)
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback(progress)
|
self._callback(progress)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self) -> dict:
|
||||||
"""Get current decoder status."""
|
"""Get current decoder status."""
|
||||||
@@ -1159,19 +1168,19 @@ class WeatherSatDecoder:
|
|||||||
if self._running and self._capture_start_time:
|
if self._running and self._capture_start_time:
|
||||||
elapsed = int(time.time() - self._capture_start_time)
|
elapsed = int(time.time() - self._capture_start_time)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'available': self._decoder is not None,
|
'available': self._decoder is not None,
|
||||||
'decoder': self._decoder,
|
'decoder': self._decoder,
|
||||||
'running': self._running,
|
'running': self._running,
|
||||||
'satellite': self._current_satellite,
|
'satellite': self._current_satellite,
|
||||||
'frequency': self._current_frequency,
|
'frequency': self._current_frequency,
|
||||||
'mode': self._current_mode,
|
'mode': self._current_mode,
|
||||||
'capture_phase': self._capture_phase,
|
'capture_phase': self._capture_phase,
|
||||||
'elapsed_seconds': elapsed,
|
'elapsed_seconds': elapsed,
|
||||||
'image_count': len(self._images),
|
'image_count': len(self._images),
|
||||||
'last_error': self._last_error_message,
|
'last_error': self._last_error_message,
|
||||||
'last_returncode': self._last_process_returncode,
|
'last_returncode': self._last_process_returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Global decoder instance
|
# Global decoder instance
|
||||||
|
|||||||
Reference in New Issue
Block a user